diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..b85126b --- /dev/null +++ b/PRD.md @@ -0,0 +1,178 @@ +# Redux Integration with IndexedDB and Flask API - PRD + +A comprehensive state management system built with Redux Toolkit, seamlessly integrating local IndexedDB storage with remote Flask API synchronization for a low-code development platform. + +**Experience Qualities**: +1. **Reliable** - Data persists locally and syncs automatically to remote storage, ensuring no data loss +2. **Transparent** - Clear visibility into sync status, connection health, and data operations +3. **Performant** - Optimized async operations with minimal UI blocking and efficient state updates + +**Complexity Level**: Complex Application (advanced functionality with multiple views) +- Sophisticated state management across 10+ Redux slices +- Dual-storage architecture (IndexedDB + Flask API) +- Real-time synchronization with conflict handling +- Integration with existing atomic component system + +## Essential Features + +### State Management with Redux +- **Functionality**: Centralized state management using Redux Toolkit with 10 specialized slices +- **Purpose**: Provides predictable state updates, time-travel debugging, and consistent data flow +- **Trigger**: Application initialization, user actions, API responses +- **Progression**: User action → Dispatch action → Reducer updates state → Components re-render → UI reflects changes +- **Success criteria**: All state changes are tracked, debuggable, and cause appropriate UI updates + +### IndexedDB Integration +- **Functionality**: Local browser storage for offline-first data persistence +- **Purpose**: Enable offline functionality and instant data access without network dependency +- **Trigger**: Redux thunk actions for CRUD operations +- **Progression**: Redux action → Async thunk → IndexedDB operation → Success/error handling → State update +- **Success criteria**: Data persists across sessions, survives page refreshes, and loads instantly + +### Flask API Synchronization +- **Functionality**: Bidirectional sync between local IndexedDB and remote Flask SQLite database +- **Purpose**: Enable data backup, cross-device sync, and collaborative features +- **Trigger**: Manual sync buttons, auto-sync timer, or after local changes +- **Progression**: User triggers sync → Bulk data collection → HTTP request to Flask → Server processes → Response updates state +- **Success criteria**: Data consistency between local and remote, connection status visible, errors handled gracefully + +### Auto-Sync System +- **Functionality**: Configurable automatic synchronization at set intervals +- **Purpose**: Reduce manual sync burden and ensure data is regularly backed up +- **Trigger**: Timer interval (default 30 seconds) when auto-sync enabled +- **Progression**: Timer fires → Check connection → Collect changed data → Push to Flask → Update sync timestamp +- **Success criteria**: Syncs occur on schedule, can be toggled on/off, interval is configurable + +### Component Tree Management +- **Functionality**: Redux-managed JSON component trees for atomic component rendering +- **Purpose**: Integrate with existing atomic component system via Redux state +- **Trigger**: Load from components.json on app initialization +- **Progression**: App loads → Fetch components.json → Parse trees → Store in Redux → Available for rendering +- **Success criteria**: Trees load successfully, can be queried by ID, support dynamic updates + +### Real-time Status Monitoring +- **Functionality**: Live display of sync status, connection health, and storage statistics +- **Purpose**: Provide transparency into system state for debugging and confidence +- **Trigger**: Continuous monitoring, updates on state changes +- **Progression**: State changes → Selectors compute derived data → UI components render current status +- **Success criteria**: Status is always accurate, updates are immediate, includes timestamps + +### Custom React Hooks +- **Functionality**: Simplified hooks (useReduxFiles, useReduxComponentTrees, useReduxSync) +- **Purpose**: Abstract Redux complexity, provide ergonomic API for common operations +- **Trigger**: Component mounting and user interactions +- **Progression**: Component calls hook → Hook uses Redux selectors/dispatch → Returns simple API → Component uses clean interface +- **Success criteria**: Hooks reduce boilerplate by 70%, are TypeScript-safe, handle loading/error states + +### CRUD Operations +- **Functionality**: Create, Read, Update, Delete for all entity types (files, models, components, etc.) +- **Purpose**: Core data manipulation across the platform +- **Trigger**: User actions in UI (buttons, forms, etc.) +- **Progression**: User interaction → Validate input → Dispatch Redux action → Update IndexedDB → Trigger sync if enabled → UI updates +- **Success criteria**: All operations complete within 100ms, provide feedback, handle errors + +## Edge Case Handling + +- **Network Failures**: Gracefully degrade to local-only mode, queue sync operations, retry with exponential backoff +- **Sync Conflicts**: Last-write-wins strategy, track timestamps, provide manual conflict resolution UI +- **Browser Storage Limits**: Monitor IndexedDB quota, warn when approaching limits, provide cleanup utilities +- **Corrupted Data**: Validate data structures on load, provide reset/repair utilities, log errors for debugging +- **Concurrent Modifications**: Use Redux's immutable updates to prevent race conditions, timestamp all changes +- **Flask API Unavailable**: Detect via health check, show connection status, continue with local operations only +- **Invalid JSON**: Validate and sanitize data before storage, provide error messages, prevent app crashes +- **Browser Compatibility**: Feature-detect IndexedDB support, provide fallback message for unsupported browsers + +## Design Direction + +The design should evoke a sense of **technical confidence** and **system transparency** - users should feel in control of their data and trust the synchronization system. The interface should communicate the health of connections, the state of operations, and the flow of data clearly through visual indicators. + +## Color Selection + +**Primary Color**: Deep Violet (`oklch(0.58 0.24 265)`) - Represents technical sophistication and the core platform identity +**Secondary Colors**: +- Dark Slate (`oklch(0.19 0.02 265)`) - Grounding color for cards and surfaces +- Muted Gray (`oklch(0.25 0.03 265)`) - De-emphasized backgrounds and borders +**Accent Color**: Bright Teal (`oklch(0.75 0.20 145)`) - Highlights successful operations, active sync, and CTAs +**Status Colors**: +- Success Green (`oklch(0.60 0.20 145)`) - Connected, synced, successful operations +- Error Red (`oklch(0.60 0.25 25)`) - Disconnected, failed operations, destructive actions +- Warning Amber (`oklch(0.70 0.15 60)`) - Pending operations, attention needed + +**Foreground/Background Pairings**: +- Primary Violet (`oklch(0.58 0.24 265)`): White text (`oklch(1 0 0)`) - Ratio 5.2:1 ✓ +- Accent Teal (`oklch(0.75 0.20 145)`): Dark Slate (`oklch(0.15 0.02 265)`) - Ratio 8.1:1 ✓ +- Dark Slate (`oklch(0.19 0.02 265)`): White text (`oklch(0.95 0.01 265)`) - Ratio 12.3:1 ✓ +- Success Green (`oklch(0.60 0.20 145)`): White text (`oklch(1 0 0)`) - Ratio 4.9:1 ✓ + +## Font Selection + +The typography should communicate **technical precision** and **code-like structure**, reinforcing the developer-focused nature of the platform. + +**Typographic Hierarchy**: +- H1 (Page Title): JetBrains Mono Bold / 32px / tight letter spacing / 1.1 line height +- H2 (Section Title): JetBrains Mono Medium / 24px / normal spacing / 1.2 line height +- H3 (Card Title): JetBrains Mono Medium / 18px / normal spacing / 1.3 line height +- Body (General Text): IBM Plex Sans Regular / 14px / normal spacing / 1.5 line height +- Small (Metadata): IBM Plex Sans Regular / 12px / wider spacing / 1.4 line height +- Code: JetBrains Mono Regular / 13px / monospace / 1.4 line height +- Buttons: IBM Plex Sans Medium / 14px / normal spacing / 1.0 line height + +## Animations + +Animations should be **purposeful and system-oriented**, emphasizing data flow and state transitions rather than decorative effects. + +- **Sync Indicator**: Pulsing animation on "Syncing..." badge to show active operation (1s ease-in-out) +- **Connection Status**: Smooth color transition when connection state changes (300ms) +- **Data Cards**: Subtle slide-in when new items appear in lists (200ms ease-out) +- **Status Badges**: Scale bounce effect on status changes for attention (150ms elastic) +- **Loading States**: Skeleton screens with shimmer effect for data loading (2s linear infinite) +- **Hover States**: Slight lift and shadow increase on interactive cards (150ms ease) +- **Error Shake**: Gentle horizontal shake on failed operations (400ms) + +## Component Selection + +**Components**: +- **Card** - Primary container for status panels and data displays, using elevated borders and hover states +- **Badge** - Status indicators (connection, sync, file counts) with variant colors for different states +- **Button** - Action triggers with variant="outline" for secondary actions, "destructive" for danger zone +- **Separator** - Visual dividers between sections within cards +- **Toaster** (Sonner) - Feedback for operations (success, error, info messages) +- **Icons** (Phosphor) - Database, CloudArrowUp, CloudArrowDown, ArrowsClockwise, CheckCircle, XCircle, Trash + +**Customizations**: +- Status cards with custom border colors based on connection health +- Animated sync badges with custom pulse animation +- File list items with hover states and smooth transitions +- Danger zone card with destructive border styling + +**States**: +- **Buttons**: Default (outline), hover (bg-muted), active (scale-95), disabled (opacity-50) +- **Badges**: Idle (outline), syncing (secondary + pulse), success (green bg), error (destructive) +- **Cards**: Default (border), hover (bg-muted/50), loading (skeleton) +- **Connection**: Connected (green badge + icon), disconnected (red badge + icon), checking (gray + spinner) + +**Icon Selection**: +- Database - Local storage (IndexedDB) +- CloudArrowUp - Push to Flask +- CloudArrowDown - Pull from Flask +- ArrowsClockwise - Sync operations and refresh +- CheckCircle - Success states +- XCircle - Error states and disconnected +- Clock - Timestamp indicators +- Trash - Delete operations +- FilePlus - Create new items + +**Spacing**: +- Container padding: p-6 (24px) +- Card gaps: gap-6 (24px) +- Internal card spacing: space-y-3 (12px) +- Button gaps: gap-2 (8px) +- Grid columns: grid-cols-1 md:grid-cols-2 lg:grid-cols-3 + +**Mobile**: +- Single column layout on mobile (grid-cols-1) +- Full-width buttons on small screens +- Collapsible cards to save vertical space +- Touch-friendly 44px minimum touch targets +- Responsive text sizing (text-sm on mobile, text-base on desktop) +- Stack sync buttons vertically on narrow screens diff --git a/REDUX_DOCUMENTATION.md b/REDUX_DOCUMENTATION.md new file mode 100644 index 0000000..ce635ca --- /dev/null +++ b/REDUX_DOCUMENTATION.md @@ -0,0 +1,450 @@ +# Redux Integration with IndexedDB and Flask API + +## Overview + +This project implements a comprehensive Redux Toolkit integration with: +- **IndexedDB** for local browser storage +- **Flask API** for remote server synchronization +- **Atomic component trees** from JSON structures + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ React Components │ +│ (UI Layer with Hooks) │ +└────────────────────────┬────────────────────────────────────┘ + │ +┌────────────────────────▼────────────────────────────────────┐ +│ Redux Toolkit Store │ +│ ┌──────────┬──────────┬────────────┬──────────┬─────────┐ │ +│ │ Project │ Files │ Models │Components│ Theme │ │ +│ │ Slice │ Slice │ Slice │ Slice │ Slice │ │ +│ └──────────┴──────────┴────────────┴──────────┴─────────┘ │ +│ ┌───────────────┬─────────────┬─────────────┬───────────┐ │ +│ │ ComponentTrees│ Workflows │ Lambdas │ Sync │ │ +│ │ Slice │ Slice │ Slice │ Slice │ │ +│ └───────────────┴─────────────┴─────────────┴───────────┘ │ +└────────────────────────┬────────────────────────────────────┘ + │ + ┌───────────────┴───────────────┐ + │ │ +┌────────▼────────┐ ┌────────▼────────┐ +│ IndexedDB │ │ Flask API │ +│ (Local Store) │◄──Sync────►│ (Remote Store) │ +└─────────────────┘ └─────────────────┘ +``` + +## Redux Slices + +### 1. Project Slice (`projectSlice.ts`) +Manages project metadata and overall project state. + +```typescript +import { useAppDispatch, useAppSelector } from '@/store' +import { createProject, loadProjects } from '@/store/slices/projectSlice' + +// Usage +const dispatch = useAppDispatch() +const projects = useAppSelector(state => state.project.projects) + +// Create a new project +dispatch(createProject({ name: 'My Project', description: 'Description' })) + +// Load all projects +dispatch(loadProjects()) +``` + +### 2. Files Slice (`filesSlice.ts`) +Manages file operations with IndexedDB and Flask sync. + +```typescript +import { useReduxFiles } from '@/hooks/use-redux-files' + +const { files, save, remove, setActive } = useReduxFiles() + +// Save a file +save({ + id: 'file-1', + name: 'Component.tsx', + content: 'export default function() {}', + language: 'typescript', + path: '/src/components', + updatedAt: Date.now() +}) +``` + +### 3. Component Trees Slice (`componentTreesSlice.ts`) +Manages JSON component trees for atomic component rendering. + +```typescript +import { useReduxComponentTrees } from '@/hooks/use-redux-component-trees' + +const { trees, updateNode, setActive } = useReduxComponentTrees() + +// Update a node in the tree +updateNode('tree-1', 'node-1', { + props: { className: 'updated-class' } +}) +``` + +### 4. Models Slice (`modelsSlice.ts`) +Manages data models and schemas. + +```typescript +import { useAppDispatch } from '@/store' +import { saveModel } from '@/store/slices/modelsSlice' + +dispatch(saveModel({ + id: 'model-1', + name: 'User', + fields: [ + { id: 'f1', name: 'email', type: 'string', required: true }, + { id: 'f2', name: 'name', type: 'string', required: true } + ], + updatedAt: Date.now() +})) +``` + +### 5. Components Slice (`componentsSlice.ts`) +Manages atomic/molecule/organism components. + +```typescript +import { useAppDispatch } from '@/store' +import { saveComponent } from '@/store/slices/componentsSlice' + +dispatch(saveComponent({ + id: 'comp-1', + name: 'Button', + type: 'atom', + code: 'export function Button() { return }', + updatedAt: Date.now() +})) +``` + +### 6. Workflows Slice (`workflowsSlice.ts`) +Manages workflow diagrams and visual programming. + +### 7. Lambdas Slice (`lambdasSlice.ts`) +Manages serverless function definitions. + +### 8. Theme Slice (`themeSlice.ts`) +Manages theme configuration and styling. + +```typescript +import { useAppDispatch } from '@/store' +import { updateThemeColors } from '@/store/slices/themeSlice' + +dispatch(updateThemeColors({ + primary: 'oklch(0.58 0.24 265)', + accent: 'oklch(0.75 0.20 145)' +})) +``` + +### 9. Settings Slice (`settingsSlice.ts`) +Manages application settings. + +```typescript +import { useAppDispatch } from '@/store' +import { toggleAutoSync, setSyncInterval } from '@/store/slices/settingsSlice' + +dispatch(toggleAutoSync()) +dispatch(setSyncInterval(60000)) // 60 seconds +``` + +### 10. Sync Slice (`syncSlice.ts`) +Manages synchronization between IndexedDB and Flask API. + +```typescript +import { useReduxSync } from '@/hooks/use-redux-sync' + +const { + syncToFlask, + syncFromFlask, + flaskConnected, + status +} = useReduxSync() + +// Push local data to Flask +syncToFlask() + +// Pull data from Flask +syncFromFlask() +``` + +## Custom Hooks + +### `useReduxFiles()` +Simplified hook for file operations. + +```typescript +const { + files, // Array of all files + activeFile, // Currently active file + activeFileId, // ID of active file + loading, // Loading state + error, // Error message + load, // Load all files + save, // Save a file + remove, // Delete a file + setActive // Set active file +} = useReduxFiles() +``` + +### `useReduxComponentTrees()` +Simplified hook for component tree operations. + +```typescript +const { + trees, // Array of all trees + activeTree, // Currently active tree + activeTreeId, // ID of active tree + loading, // Loading state + error, // Error message + load, // Load all trees + save, // Save a tree + remove, // Delete a tree + setActive, // Set active tree + updateNode // Update a specific node +} = useReduxComponentTrees() +``` + +### `useReduxSync()` +Simplified hook for synchronization operations. + +```typescript +const { + status, // 'idle' | 'syncing' | 'success' | 'error' + lastSyncedAt, // Timestamp of last sync + flaskConnected, // Flask connection status + flaskStats, // Flask storage statistics + error, // Error message + syncToFlask, // Push to Flask + syncFromFlask, // Pull from Flask + checkConnection, // Check Flask connection + clearFlaskData, // Clear Flask storage + reset // Reset sync status +} = useReduxSync() +``` + +## Flask API Middleware + +The Flask sync middleware (`flaskSync.ts`) provides: + +### Individual Sync Operations +```typescript +import { syncToFlask, fetchFromFlask } from '@/store/middleware/flaskSync' + +// Sync a single item to Flask +await syncToFlask('files', 'file-1', fileData) + +// Fetch a single item from Flask +const data = await fetchFromFlask('files', 'file-1') + +// Delete from Flask +await syncToFlask('files', 'file-1', null, 'delete') +``` + +### Bulk Operations +```typescript +import { + syncAllToFlask, + fetchAllFromFlask, + getFlaskStats, + clearFlaskStorage +} from '@/store/middleware/flaskSync' + +// Push all data to Flask +await syncAllToFlask({ + 'files:file-1': fileData, + 'models:model-1': modelData +}) + +// Pull all data from Flask +const allData = await fetchAllFromFlask() + +// Get Flask statistics +const stats = await getFlaskStats() +// Returns: { total_keys, total_size_bytes, database_path } + +// Clear Flask storage +await clearFlaskStorage() +``` + +## IndexedDB Integration + +The IndexedDB integration (`db.ts`) provides direct database access: + +```typescript +import { db } from '@/lib/db' + +// Get a single item +const file = await db.get('files', 'file-1') + +// Get all items +const files = await db.getAll('files') + +// Save an item +await db.put('files', fileData) + +// Delete an item +await db.delete('files', 'file-1') + +// Clear a store +await db.clear('files') + +// Query by index +const results = await db.query('files', 'path', '/src/components') + +// Count items +const count = await db.count('files') +``` + +## Flask API Endpoints + +The Flask backend provides these REST endpoints: + +### Storage Operations +- `GET /api/storage/keys` - List all keys +- `GET /api/storage/` - Get value by key +- `PUT /api/storage/` - Set/update value +- `DELETE /api/storage/` - Delete value +- `POST /api/storage/clear` - Clear all data + +### Bulk Operations +- `GET /api/storage/export` - Export all data +- `POST /api/storage/import` - Import data + +### Utilities +- `GET /api/storage/stats` - Get storage statistics +- `GET /health` - Health check + +## Auto-Sync Feature + +Redux automatically syncs to Flask when `autoSync` is enabled: + +```typescript +import { useAppDispatch } from '@/store' +import { toggleAutoSync, setSyncInterval } from '@/store/slices/settingsSlice' + +// Enable auto-sync +dispatch(toggleAutoSync()) + +// Set sync interval (milliseconds) +dispatch(setSyncInterval(30000)) // Every 30 seconds +``` + +The sync hook automatically: +1. Checks Flask connection on mount +2. Starts auto-sync interval if enabled +3. Syncs all Redux state to Flask periodically +4. Updates sync status and timestamps + +## Environment Configuration + +Set the Flask API URL via environment variable: + +```env +VITE_FLASK_API_URL=http://localhost:5001 +``` + +Or it defaults to `http://localhost:5001`. + +## Demo Component + +The `ReduxIntegrationDemo` component showcases: +- Redux store state visualization +- IndexedDB operations +- Flask API connectivity +- Real-time sync status +- CRUD operations for files +- Component tree display + +To use it: + +```typescript +import { Provider } from 'react-redux' +import { store } from '@/store' +import { ReduxIntegrationDemo } from '@/components/ReduxIntegrationDemo' + +function App() { + return ( + + + + ) +} +``` + +## Best Practices + +### 1. Always Use Typed Hooks +```typescript +// ✅ Good +import { useAppDispatch, useAppSelector } from '@/store' + +// ❌ Avoid +import { useDispatch, useSelector } from 'react-redux' +``` + +### 2. Use Custom Hooks for Common Operations +```typescript +// ✅ Good +const { files, save } = useReduxFiles() + +// ❌ Verbose +const dispatch = useAppDispatch() +const files = useAppSelector(state => state.files.files) +dispatch(saveFile(fileData)) +``` + +### 3. Handle Loading and Error States +```typescript +const { files, loading, error } = useReduxFiles() + +if (loading) return +if (error) return +return +``` + +### 4. Use Async Thunks for Side Effects +```typescript +// Redux automatically handles loading/error states +export const saveFile = createAsyncThunk( + 'files/saveFile', + async (file: FileItem) => { + await db.put('files', file) + await syncToFlask('files', file.id, file) + return file + } +) +``` + +### 5. Leverage Auto-Sync for Background Operations +Enable auto-sync for seamless background synchronization without manual triggers. + +## Troubleshooting + +### Flask Connection Issues +- Check Flask is running on the configured port +- Verify CORS settings in Flask app +- Check network/firewall settings + +### IndexedDB Issues +- Clear browser data if corrupted +- Check browser console for errors +- Verify IndexedDB support in browser + +### Sync Conflicts +- Flask always overwrites on push +- Pull from Flask overwrites local data +- Implement conflict resolution if needed + +## Next Steps + +1. Implement conflict resolution for sync operations +2. Add optimistic updates for better UX +3. Implement offline-first patterns +4. Add data migration utilities +5. Create Redux DevTools integration +6. Add comprehensive error recovery diff --git a/package-lock.json b/package-lock.json index 7ce96a5..3fa92d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", + "@reduxjs/toolkit": "^2.11.2", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-query": "^5.83.1", @@ -61,10 +62,12 @@ "react-dom": "^19.0.0", "react-error-boundary": "^6.0.0", "react-hook-form": "^7.54.2", + "react-redux": "^9.2.0", "react-resizable-panels": "^2.1.7", "react-router-dom": "^7.12.0", "reactflow": "^11.11.4", "recharts": "^2.15.1", + "redux-persist": "^6.0.0", "sass": "^1.97.2", "sonner": "^2.0.1", "tailwind-merge": "^3.0.2", @@ -822,6 +825,7 @@ "version": "0.3.11", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -953,7 +957,6 @@ "node_modules/@octokit/core": { "version": "6.1.6", "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", @@ -3030,6 +3033,32 @@ "react-dom": ">=17" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.47", "dev": true, @@ -3317,6 +3346,12 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "license": "MIT" @@ -4291,7 +4326,6 @@ "version": "19.2.7", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4300,7 +4334,6 @@ "version": "19.2.3", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4308,7 +4341,14 @@ "node_modules/@types/trusted-types": { "version": "2.0.7", "license": "MIT", - "optional": true + "optional": true, + "peer": true + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.48.0", @@ -4350,7 +4390,6 @@ "version": "8.48.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -4645,7 +4684,6 @@ "version": "8.15.0", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4730,7 +4768,8 @@ "node_modules/buffer-from": { "version": "1.1.2", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/callsites": { "version": "3.1.0", @@ -5129,7 +5168,6 @@ "node_modules/d3-selection": { "version": "3.0.0", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -5252,14 +5290,14 @@ "node_modules/dompurify": { "version": "3.2.7", "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "node_modules/embla-carousel": { "version": "8.6.0", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -5344,7 +5382,6 @@ "version": "9.39.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5714,6 +5751,16 @@ "version": "3.0.6", "license": "MIT" }, + "node_modules/immer": { + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", + "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/immutable": { "version": "5.1.4", "license": "MIT" @@ -6176,6 +6223,7 @@ "node_modules/monaco-editor": { "version": "0.55.1", "license": "MIT", + "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -6184,6 +6232,7 @@ "node_modules/monaco-editor/node_modules/marked": { "version": "14.0.0", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -6442,7 +6491,6 @@ "node_modules/react": { "version": "19.2.0", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6477,7 +6525,6 @@ "node_modules/react-dom": { "version": "19.2.0", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6498,7 +6545,6 @@ "node_modules/react-hook-form": { "version": "7.67.0", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -6514,6 +6560,29 @@ "version": "18.3.1", "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "license": "MIT", @@ -6735,6 +6804,36 @@ "decimal.js-light": "^2.4.1" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-persist": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz", + "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==", + "license": "MIT", + "peerDependencies": { + "redux": ">4.0.0" + } + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "dev": true, @@ -6797,7 +6896,6 @@ "node_modules/sass": { "version": "1.97.2", "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -6869,6 +6967,7 @@ "version": "0.6.1", "license": "BSD-3-Clause", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6884,6 +6983,7 @@ "version": "0.5.21", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -6936,8 +7036,7 @@ }, "node_modules/tailwindcss": { "version": "4.1.17", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -6954,6 +7053,7 @@ "version": "5.46.0", "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -6970,7 +7070,8 @@ "node_modules/terser/node_modules/commander": { "version": "2.20.3", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/three": { "version": "0.175.0", @@ -7038,7 +7139,6 @@ "version": "5.7.3", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7069,11 +7169,6 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/undici-types": { - "version": "6.21.0", - "license": "MIT", - "optional": true - }, "node_modules/universal-github-app-jwt": { "version": "2.2.2", "license": "MIT" @@ -7185,7 +7280,6 @@ "node_modules/vite": { "version": "7.3.1", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index 0654502..d33b69f 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", + "@reduxjs/toolkit": "^2.11.2", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-query": "^5.83.1", @@ -76,10 +77,12 @@ "react-dom": "^19.0.0", "react-error-boundary": "^6.0.0", "react-hook-form": "^7.54.2", + "react-redux": "^9.2.0", "react-resizable-panels": "^2.1.7", "react-router-dom": "^7.12.0", "reactflow": "^11.11.4", "recharts": "^2.15.1", + "redux-persist": "^6.0.0", "sass": "^1.97.2", "sonner": "^2.0.1", "tailwind-merge": "^3.0.2", diff --git a/src/App.redux-demo.tsx b/src/App.redux-demo.tsx new file mode 100644 index 0000000..b9538aa --- /dev/null +++ b/src/App.redux-demo.tsx @@ -0,0 +1,17 @@ +import { Provider } from 'react-redux' +import { store } from '@/store' +import { ReduxIntegrationDemo } from '@/components/ReduxIntegrationDemo' +import { Toaster } from '@/components/ui/sonner' + +function App() { + return ( + + + + + + + ) +} + +export default App diff --git a/src/components/ReduxIntegrationDemo.tsx b/src/components/ReduxIntegrationDemo.tsx new file mode 100644 index 0000000..9ec3826 --- /dev/null +++ b/src/components/ReduxIntegrationDemo.tsx @@ -0,0 +1,335 @@ +import { useEffect } from 'react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { toast } from 'sonner' +import { + ArrowsClockwise, + Database, + CloudArrowUp, + CloudArrowDown, + CheckCircle, + XCircle, + Clock, + Trash, + FilePlus +} from '@phosphor-icons/react' +import { useReduxFiles } from '@/hooks/use-redux-files' +import { useReduxComponentTrees } from '@/hooks/use-redux-component-trees' +import { useReduxSync } from '@/hooks/use-redux-sync' +import { useAppSelector } from '@/store' + +export function ReduxIntegrationDemo() { + const { files, load: loadFiles, save: saveFile, remove: removeFile } = useReduxFiles() + const { trees, load: loadTrees } = useReduxComponentTrees() + const { + status, + lastSyncedAt, + flaskConnected, + flaskStats, + syncToFlask, + syncFromFlask, + checkConnection, + clearFlaskData + } = useReduxSync() + + const settings = useAppSelector((state) => state.settings.settings) + + useEffect(() => { + loadFiles() + loadTrees() + }, [loadFiles, loadTrees]) + + const handleCreateTestFile = () => { + const newFile = { + id: `file-${Date.now()}`, + name: `test-${Date.now()}.tsx`, + content: '// Test file created via Redux', + language: 'typescript', + path: '/test', + updatedAt: Date.now(), + } + saveFile(newFile) + toast.success('Test file created and saved to IndexedDB') + } + + const handleDeleteFile = (fileId: string) => { + removeFile(fileId) + toast.success('File deleted from IndexedDB') + } + + const handleSyncUp = () => { + syncToFlask() + toast.info('Syncing to Flask API...') + } + + const handleSyncDown = () => { + syncFromFlask() + toast.info('Syncing from Flask API...') + } + + const handleClearFlask = () => { + clearFlaskData() + toast.warning('Clearing Flask storage...') + } + + const getSyncStatusBadge = () => { + switch (status) { + case 'idle': + return Idle + case 'syncing': + return Syncing... + case 'success': + return Success + case 'error': + return Error + } + } + + const getConnectionBadge = () => { + return flaskConnected ? ( + + Connected + + ) : ( + + Disconnected + + ) + } + + return ( + + + + Redux Integration Demo + + Comprehensive Redux Toolkit integration with IndexedDB and Flask API synchronization + + + + + + + + + IndexedDB Status + + Local browser storage + + + + Files + {files.length} + + + Component Trees + {trees.length} + + + + + Create Test File + + + + + + + + + Flask API Status + + Remote server connection + + + + Connection + {getConnectionBadge()} + + {flaskStats && ( + <> + + Total Keys + {flaskStats.totalKeys} + + + Storage Size + + {(flaskStats.totalSizeBytes / 1024).toFixed(2)} KB + + + > + )} + + + + Check Connection + + + + + + + + + Sync Status + + Data synchronization + + + + Status + {getSyncStatusBadge()} + + + Auto Sync + + {settings.autoSync ? 'Enabled' : 'Disabled'} + + + {lastSyncedAt && ( + + Last Sync + + + {new Date(lastSyncedAt).toLocaleTimeString()} + + + )} + + + + + Push + + + + Pull + + + + + + + + + Files in Redux Store + + Files managed by Redux and synced with IndexedDB/Flask + + + + {files.length === 0 ? ( + + + No files yet. Create a test file to get started. + + ) : ( + + {files.map((file) => ( + + + {file.name} + + {file.path} • Updated {new Date(file.updatedAt).toLocaleString()} + + + handleDeleteFile(file.id)} + > + + + + ))} + + )} + + + + + + Component Trees in Redux Store + + JSON component trees loaded from components.json + + + + {trees.length === 0 ? ( + + + No component trees loaded yet. + + ) : ( + + {trees.map((tree) => ( + + + {tree.name} + {tree.description && ( + {tree.description} + )} + + + {tree.root.type} + + + ))} + + )} + + + + + + Danger Zone + + Irreversible operations - use with caution + + + + + + Clear Flask Storage + + + + + + ) +} diff --git a/src/hooks/use-redux-component-trees.ts b/src/hooks/use-redux-component-trees.ts new file mode 100644 index 0000000..23ba06f --- /dev/null +++ b/src/hooks/use-redux-component-trees.ts @@ -0,0 +1,54 @@ +import { useAppDispatch, useAppSelector } from '@/store' +import { + loadComponentTrees, + saveComponentTree, + deleteComponentTree, + setActiveTree, + updateTreeNode, + ComponentTree, + ComponentTreeNode, +} from '@/store/slices/componentTreesSlice' +import { useCallback } from 'react' + +export function useReduxComponentTrees() { + const dispatch = useAppDispatch() + const trees = useAppSelector((state) => state.componentTrees.trees) + const activeTreeId = useAppSelector((state) => state.componentTrees.activeTreeId) + const loading = useAppSelector((state) => state.componentTrees.loading) + const error = useAppSelector((state) => state.componentTrees.error) + + const activeTree = trees.find(t => t.id === activeTreeId) + + const load = useCallback(() => { + dispatch(loadComponentTrees()) + }, [dispatch]) + + const save = useCallback((tree: ComponentTree) => { + dispatch(saveComponentTree(tree)) + }, [dispatch]) + + const remove = useCallback((treeId: string) => { + dispatch(deleteComponentTree(treeId)) + }, [dispatch]) + + const setActive = useCallback((treeId: string) => { + dispatch(setActiveTree(treeId)) + }, [dispatch]) + + const updateNode = useCallback((treeId: string, nodeId: string, updates: Partial) => { + dispatch(updateTreeNode({ treeId, nodeId, updates })) + }, [dispatch]) + + return { + trees, + activeTree, + activeTreeId, + loading, + error, + load, + save, + remove, + setActive, + updateNode, + } +} diff --git a/src/hooks/use-redux-files.ts b/src/hooks/use-redux-files.ts new file mode 100644 index 0000000..70ea7aa --- /dev/null +++ b/src/hooks/use-redux-files.ts @@ -0,0 +1,47 @@ +import { useAppDispatch, useAppSelector } from '@/store' +import { + loadFiles, + saveFile, + deleteFile, + setActiveFile, + FileItem +} from '@/store/slices/filesSlice' +import { useCallback } from 'react' + +export function useReduxFiles() { + const dispatch = useAppDispatch() + const files = useAppSelector((state) => state.files.files) + const activeFileId = useAppSelector((state) => state.files.activeFileId) + const loading = useAppSelector((state) => state.files.loading) + const error = useAppSelector((state) => state.files.error) + + const activeFile = files.find(f => f.id === activeFileId) + + const load = useCallback(() => { + dispatch(loadFiles()) + }, [dispatch]) + + const save = useCallback((file: FileItem) => { + dispatch(saveFile(file)) + }, [dispatch]) + + const remove = useCallback((fileId: string) => { + dispatch(deleteFile(fileId)) + }, [dispatch]) + + const setActive = useCallback((fileId: string) => { + dispatch(setActiveFile(fileId)) + }, [dispatch]) + + return { + files, + activeFile, + activeFileId, + loading, + error, + load, + save, + remove, + setActive, + } +} diff --git a/src/hooks/use-redux-sync.ts b/src/hooks/use-redux-sync.ts new file mode 100644 index 0000000..36af484 --- /dev/null +++ b/src/hooks/use-redux-sync.ts @@ -0,0 +1,67 @@ +import { useAppDispatch, useAppSelector } from '@/store' +import { + syncToFlaskBulk, + syncFromFlaskBulk, + checkFlaskConnection, + clearFlask, + resetSyncStatus, +} from '@/store/slices/syncSlice' +import { useCallback, useEffect } from 'react' + +export function useReduxSync() { + const dispatch = useAppDispatch() + const status = useAppSelector((state) => state.sync.status) + const lastSyncedAt = useAppSelector((state) => state.sync.lastSyncedAt) + const flaskConnected = useAppSelector((state) => state.sync.flaskConnected) + const flaskStats = useAppSelector((state) => state.sync.flaskStats) + const error = useAppSelector((state) => state.sync.error) + const autoSync = useAppSelector((state) => state.settings.settings.autoSync) + const syncInterval = useAppSelector((state) => state.settings.settings.syncInterval) + + const syncToFlask = useCallback(() => { + dispatch(syncToFlaskBulk()) + }, [dispatch]) + + const syncFromFlask = useCallback(() => { + dispatch(syncFromFlaskBulk()) + }, [dispatch]) + + const checkConnection = useCallback(() => { + dispatch(checkFlaskConnection()) + }, [dispatch]) + + const clearFlaskData = useCallback(() => { + dispatch(clearFlask()) + }, [dispatch]) + + const reset = useCallback(() => { + dispatch(resetSyncStatus()) + }, [dispatch]) + + useEffect(() => { + checkConnection() + }, [checkConnection]) + + useEffect(() => { + if (autoSync && flaskConnected) { + const interval = setInterval(() => { + syncToFlask() + }, syncInterval) + + return () => clearInterval(interval) + } + }, [autoSync, flaskConnected, syncInterval, syncToFlask]) + + return { + status, + lastSyncedAt, + flaskConnected, + flaskStats, + error, + syncToFlask, + syncFromFlask, + checkConnection, + clearFlaskData, + reset, + } +} diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..8763f27 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,39 @@ +import { configureStore } from '@reduxjs/toolkit' +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' +import projectReducer from './slices/projectSlice' +import filesReducer from './slices/filesSlice' +import modelsReducer from './slices/modelsSlice' +import componentsReducer from './slices/componentsSlice' +import componentTreesReducer from './slices/componentTreesSlice' +import workflowsReducer from './slices/workflowsSlice' +import lambdasReducer from './slices/lambdasSlice' +import themeReducer from './slices/themeSlice' +import settingsReducer from './slices/settingsSlice' +import syncReducer from './slices/syncSlice' + +export const store = configureStore({ + reducer: { + project: projectReducer, + files: filesReducer, + models: modelsReducer, + components: componentsReducer, + componentTrees: componentTreesReducer, + workflows: workflowsReducer, + lambdas: lambdasReducer, + theme: themeReducer, + settings: settingsReducer, + sync: syncReducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: { + ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'], + }, + }), +}) + +export type RootState = ReturnType +export type AppDispatch = typeof store.dispatch + +export const useAppDispatch = () => useDispatch() +export const useAppSelector: TypedUseSelectorHook = useSelector diff --git a/src/store/middleware/flaskSync.ts b/src/store/middleware/flaskSync.ts new file mode 100644 index 0000000..99a705d --- /dev/null +++ b/src/store/middleware/flaskSync.ts @@ -0,0 +1,137 @@ +const FLASK_API_URL = import.meta.env.VITE_FLASK_API_URL || 'http://localhost:5001' + +export type SyncOperation = 'put' | 'delete' + +export async function syncToFlask( + storeName: string, + key: string, + value: any, + operation: SyncOperation = 'put' +): Promise { + try { + const url = `${FLASK_API_URL}/api/storage/${storeName}:${key}` + + if (operation === 'delete') { + const response = await fetch(url, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok && response.status !== 404) { + throw new Error(`Flask sync failed: ${response.statusText}`) + } + } else { + const response = await fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ value }), + }) + + if (!response.ok) { + throw new Error(`Flask sync failed: ${response.statusText}`) + } + } + } catch (error) { + console.error('[FlaskSync] Error syncing to Flask:', error) + } +} + +export async function fetchFromFlask( + storeName: string, + key: string +): Promise { + try { + const url = `${FLASK_API_URL}/api/storage/${storeName}:${key}` + const response = await fetch(url) + + if (response.status === 404) { + return null + } + + if (!response.ok) { + throw new Error(`Flask fetch failed: ${response.statusText}`) + } + + const data = await response.json() + return data.value + } catch (error) { + console.error('[FlaskSync] Error fetching from Flask:', error) + return null + } +} + +export async function syncAllToFlask(data: Record): Promise { + try { + const url = `${FLASK_API_URL}/api/storage/import` + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }) + + if (!response.ok) { + throw new Error(`Flask bulk sync failed: ${response.statusText}`) + } + } catch (error) { + console.error('[FlaskSync] Error bulk syncing to Flask:', error) + throw error + } +} + +export async function fetchAllFromFlask(): Promise> { + try { + const url = `${FLASK_API_URL}/api/storage/export` + const response = await fetch(url) + + if (!response.ok) { + throw new Error(`Flask bulk fetch failed: ${response.statusText}`) + } + + return await response.json() + } catch (error) { + console.error('[FlaskSync] Error bulk fetching from Flask:', error) + throw error + } +} + +export async function getFlaskStats(): Promise<{ + total_keys: number + total_size_bytes: number + database_path: string +}> { + try { + const url = `${FLASK_API_URL}/api/storage/stats` + const response = await fetch(url) + + if (!response.ok) { + throw new Error(`Flask stats failed: ${response.statusText}`) + } + + return await response.json() + } catch (error) { + console.error('[FlaskSync] Error fetching Flask stats:', error) + throw error + } +} + +export async function clearFlaskStorage(): Promise { + try { + const url = `${FLASK_API_URL}/api/storage/clear` + const response = await fetch(url, { + method: 'POST', + }) + + if (!response.ok) { + throw new Error(`Flask clear failed: ${response.statusText}`) + } + } catch (error) { + console.error('[FlaskSync] Error clearing Flask storage:', error) + throw error + } +} diff --git a/src/store/slices/componentTreesSlice.ts b/src/store/slices/componentTreesSlice.ts new file mode 100644 index 0000000..fc167bd --- /dev/null +++ b/src/store/slices/componentTreesSlice.ts @@ -0,0 +1,172 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit' +import { db } from '@/lib/db' +import { syncToFlask, fetchFromFlask } from '@/store/middleware/flaskSync' + +export interface ComponentTree { + id: string + name: string + description?: string + root: ComponentTreeNode + metadata?: Record + updatedAt: number +} + +export interface ComponentTreeNode { + id: string + type: string + props?: Record + children?: ComponentTreeNode[] + bindings?: DataBinding[] +} + +export interface DataBinding { + property: string + source: 'state' | 'props' | 'api' | 'computed' + path: string + transform?: string +} + +interface ComponentTreesState { + trees: ComponentTree[] + activeTreeId: string | null + loading: boolean + error: string | null +} + +const initialState: ComponentTreesState = { + trees: [], + activeTreeId: null, + loading: false, + error: null, +} + +export const loadComponentTrees = createAsyncThunk( + 'componentTrees/loadTrees', + async () => { + try { + const response = await fetch('/components.json') + if (!response.ok) { + return [] + } + const data = await response.json() + return data.componentTrees || [] + } catch (error) { + console.error('Failed to load component trees:', error) + return [] + } + } +) + +export const saveComponentTree = createAsyncThunk( + 'componentTrees/saveTree', + async (tree: ComponentTree) => { + await syncToFlask('componentTrees', tree.id, tree) + return tree + } +) + +export const deleteComponentTree = createAsyncThunk( + 'componentTrees/deleteTree', + async (treeId: string) => { + await syncToFlask('componentTrees', treeId, null, 'delete') + return treeId + } +) + +export const syncTreeFromFlask = createAsyncThunk( + 'componentTrees/syncFromFlask', + async (treeId: string) => { + const tree = await fetchFromFlask('componentTrees', treeId) + return tree + } +) + +const componentTreesSlice = createSlice({ + name: 'componentTrees', + initialState, + reducers: { + setActiveTree: (state, action: PayloadAction) => { + state.activeTreeId = action.payload + }, + clearActiveTree: (state) => { + state.activeTreeId = null + }, + addTree: (state, action: PayloadAction) => { + state.trees.push(action.payload) + }, + updateTree: (state, action: PayloadAction) => { + const index = state.trees.findIndex(t => t.id === action.payload.id) + if (index !== -1) { + state.trees[index] = action.payload + } + }, + updateTreeNode: (state, action: PayloadAction<{ treeId: string; nodeId: string; updates: Partial }>) => { + const tree = state.trees.find(t => t.id === action.payload.treeId) + if (tree) { + const updateNode = (node: ComponentTreeNode): ComponentTreeNode => { + if (node.id === action.payload.nodeId) { + return { ...node, ...action.payload.updates } + } + if (node.children) { + return { + ...node, + children: node.children.map(updateNode) + } + } + return node + } + tree.root = updateNode(tree.root) + tree.updatedAt = Date.now() + } + }, + }, + extraReducers: (builder) => { + builder + .addCase(loadComponentTrees.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(loadComponentTrees.fulfilled, (state, action) => { + state.loading = false + state.trees = action.payload + }) + .addCase(loadComponentTrees.rejected, (state, action) => { + state.loading = false + state.error = action.error.message || 'Failed to load component trees' + }) + .addCase(saveComponentTree.fulfilled, (state, action) => { + const index = state.trees.findIndex(t => t.id === action.payload.id) + if (index !== -1) { + state.trees[index] = action.payload + } else { + state.trees.push(action.payload) + } + }) + .addCase(deleteComponentTree.fulfilled, (state, action) => { + state.trees = state.trees.filter(t => t.id !== action.payload) + if (state.activeTreeId === action.payload) { + state.activeTreeId = null + } + }) + .addCase(syncTreeFromFlask.fulfilled, (state, action) => { + if (action.payload) { + const index = state.trees.findIndex(t => t.id === action.payload.id) + if (index !== -1) { + state.trees[index] = action.payload + } else { + state.trees.push(action.payload) + } + } + }) + }, +}) + +export const { + setActiveTree, + clearActiveTree, + addTree, + updateTree, + updateTreeNode +} = componentTreesSlice.actions + +export default componentTreesSlice.reducer diff --git a/src/store/slices/componentsSlice.ts b/src/store/slices/componentsSlice.ts new file mode 100644 index 0000000..12c7fab --- /dev/null +++ b/src/store/slices/componentsSlice.ts @@ -0,0 +1,121 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit' +import { db } from '@/lib/db' +import { syncToFlask, fetchFromFlask } from '@/store/middleware/flaskSync' + +export interface Component { + id: string + name: string + type: 'atom' | 'molecule' | 'organism' + code: string + props?: ComponentProp[] + metadata?: Record + updatedAt: number +} + +export interface ComponentProp { + name: string + type: string + required: boolean + defaultValue?: any + description?: string +} + +interface ComponentsState { + components: Component[] + activeComponentId: string | null + loading: boolean + error: string | null +} + +const initialState: ComponentsState = { + components: [], + activeComponentId: null, + loading: false, + error: null, +} + +export const loadComponents = createAsyncThunk( + 'components/loadComponents', + async () => { + const components = await db.getAll('components') + return components as Component[] + } +) + +export const saveComponent = createAsyncThunk( + 'components/saveComponent', + async (component: Component) => { + await db.put('components', component) + await syncToFlask('components', component.id, component) + return component + } +) + +export const deleteComponent = createAsyncThunk( + 'components/deleteComponent', + async (componentId: string) => { + await db.delete('components', componentId) + await syncToFlask('components', componentId, null, 'delete') + return componentId + } +) + +const componentsSlice = createSlice({ + name: 'components', + initialState, + reducers: { + setActiveComponent: (state, action: PayloadAction) => { + state.activeComponentId = action.payload + }, + clearActiveComponent: (state) => { + state.activeComponentId = null + }, + addComponent: (state, action: PayloadAction) => { + state.components.push(action.payload) + }, + updateComponent: (state, action: PayloadAction) => { + const index = state.components.findIndex(c => c.id === action.payload.id) + if (index !== -1) { + state.components[index] = action.payload + } + }, + }, + extraReducers: (builder) => { + builder + .addCase(loadComponents.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(loadComponents.fulfilled, (state, action) => { + state.loading = false + state.components = action.payload + }) + .addCase(loadComponents.rejected, (state, action) => { + state.loading = false + state.error = action.error.message || 'Failed to load components' + }) + .addCase(saveComponent.fulfilled, (state, action) => { + const index = state.components.findIndex(c => c.id === action.payload.id) + if (index !== -1) { + state.components[index] = action.payload + } else { + state.components.push(action.payload) + } + }) + .addCase(deleteComponent.fulfilled, (state, action) => { + state.components = state.components.filter(c => c.id !== action.payload) + if (state.activeComponentId === action.payload) { + state.activeComponentId = null + } + }) + }, +}) + +export const { + setActiveComponent, + clearActiveComponent, + addComponent, + updateComponent +} = componentsSlice.actions + +export default componentsSlice.reducer diff --git a/src/store/slices/filesSlice.ts b/src/store/slices/filesSlice.ts new file mode 100644 index 0000000..893713d --- /dev/null +++ b/src/store/slices/filesSlice.ts @@ -0,0 +1,124 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit' +import { db } from '@/lib/db' +import { syncToFlask, fetchFromFlask } from '@/store/middleware/flaskSync' + +export interface FileItem { + id: string + name: string + content: string + language: string + path: string + updatedAt: number +} + +interface FilesState { + files: FileItem[] + activeFileId: string | null + loading: boolean + error: string | null +} + +const initialState: FilesState = { + files: [], + activeFileId: null, + loading: false, + error: null, +} + +export const loadFiles = createAsyncThunk('files/loadFiles', async () => { + const files = await db.getAll('files') + return files as FileItem[] +}) + +export const saveFile = createAsyncThunk( + 'files/saveFile', + async (file: FileItem) => { + await db.put('files', file) + await syncToFlask('files', file.id, file) + return file + } +) + +export const deleteFile = createAsyncThunk( + 'files/deleteFile', + async (fileId: string) => { + await db.delete('files', fileId) + await syncToFlask('files', fileId, null, 'delete') + return fileId + } +) + +export const syncFileFromFlask = createAsyncThunk( + 'files/syncFromFlask', + async (fileId: string) => { + const file = await fetchFromFlask('files', fileId) + if (file) { + await db.put('files', file) + } + return file + } +) + +const filesSlice = createSlice({ + name: 'files', + initialState, + reducers: { + setActiveFile: (state, action: PayloadAction) => { + state.activeFileId = action.payload + }, + clearActiveFile: (state) => { + state.activeFileId = null + }, + addFile: (state, action: PayloadAction) => { + state.files.push(action.payload) + }, + updateFile: (state, action: PayloadAction) => { + const index = state.files.findIndex(f => f.id === action.payload.id) + if (index !== -1) { + state.files[index] = action.payload + } + }, + }, + extraReducers: (builder) => { + builder + .addCase(loadFiles.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(loadFiles.fulfilled, (state, action) => { + state.loading = false + state.files = action.payload + }) + .addCase(loadFiles.rejected, (state, action) => { + state.loading = false + state.error = action.error.message || 'Failed to load files' + }) + .addCase(saveFile.fulfilled, (state, action) => { + const index = state.files.findIndex(f => f.id === action.payload.id) + if (index !== -1) { + state.files[index] = action.payload + } else { + state.files.push(action.payload) + } + }) + .addCase(deleteFile.fulfilled, (state, action) => { + state.files = state.files.filter(f => f.id !== action.payload) + if (state.activeFileId === action.payload) { + state.activeFileId = null + } + }) + .addCase(syncFileFromFlask.fulfilled, (state, action) => { + if (action.payload) { + const index = state.files.findIndex(f => f.id === action.payload.id) + if (index !== -1) { + state.files[index] = action.payload + } else { + state.files.push(action.payload) + } + } + }) + }, +}) + +export const { setActiveFile, clearActiveFile, addFile, updateFile } = filesSlice.actions +export default filesSlice.reducer diff --git a/src/store/slices/lambdasSlice.ts b/src/store/slices/lambdasSlice.ts new file mode 100644 index 0000000..7a062fe --- /dev/null +++ b/src/store/slices/lambdasSlice.ts @@ -0,0 +1,71 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { syncToFlask } from '@/store/middleware/flaskSync' + +export interface Lambda { + id: string + name: string + description?: string + code: string + runtime: string + handler: string + updatedAt: number +} + +interface LambdasState { + lambdas: Lambda[] + activeLambdaId: string | null + loading: boolean + error: string | null +} + +const initialState: LambdasState = { + lambdas: [], + activeLambdaId: null, + loading: false, + error: null, +} + +const lambdasSlice = createSlice({ + name: 'lambdas', + initialState, + reducers: { + setActiveLambda: (state, action: PayloadAction) => { + state.activeLambdaId = action.payload + }, + clearActiveLambda: (state) => { + state.activeLambdaId = null + }, + addLambda: (state, action: PayloadAction) => { + state.lambdas.push(action.payload) + syncToFlask('lambdas', action.payload.id, action.payload).catch(console.error) + }, + updateLambda: (state, action: PayloadAction) => { + const index = state.lambdas.findIndex(l => l.id === action.payload.id) + if (index !== -1) { + state.lambdas[index] = action.payload + syncToFlask('lambdas', action.payload.id, action.payload).catch(console.error) + } + }, + deleteLambda: (state, action: PayloadAction) => { + state.lambdas = state.lambdas.filter(l => l.id !== action.payload) + if (state.activeLambdaId === action.payload) { + state.activeLambdaId = null + } + syncToFlask('lambdas', action.payload, null, 'delete').catch(console.error) + }, + setLambdas: (state, action: PayloadAction) => { + state.lambdas = action.payload + }, + }, +}) + +export const { + setActiveLambda, + clearActiveLambda, + addLambda, + updateLambda, + deleteLambda, + setLambdas +} = lambdasSlice.actions + +export default lambdasSlice.reducer diff --git a/src/store/slices/modelsSlice.ts b/src/store/slices/modelsSlice.ts new file mode 100644 index 0000000..0544658 --- /dev/null +++ b/src/store/slices/modelsSlice.ts @@ -0,0 +1,109 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit' +import { db } from '@/lib/db' +import { syncToFlask, fetchFromFlask } from '@/store/middleware/flaskSync' + +export interface Model { + id: string + name: string + fields: ModelField[] + updatedAt: number +} + +export interface ModelField { + id: string + name: string + type: string + required: boolean + defaultValue?: any +} + +interface ModelsState { + models: Model[] + activeModelId: string | null + loading: boolean + error: string | null +} + +const initialState: ModelsState = { + models: [], + activeModelId: null, + loading: false, + error: null, +} + +export const loadModels = createAsyncThunk('models/loadModels', async () => { + const models = await db.getAll('models') + return models as Model[] +}) + +export const saveModel = createAsyncThunk( + 'models/saveModel', + async (model: Model) => { + await db.put('models', model) + await syncToFlask('models', model.id, model) + return model + } +) + +export const deleteModel = createAsyncThunk( + 'models/deleteModel', + async (modelId: string) => { + await db.delete('models', modelId) + await syncToFlask('models', modelId, null, 'delete') + return modelId + } +) + +const modelsSlice = createSlice({ + name: 'models', + initialState, + reducers: { + setActiveModel: (state, action: PayloadAction) => { + state.activeModelId = action.payload + }, + clearActiveModel: (state) => { + state.activeModelId = null + }, + addModel: (state, action: PayloadAction) => { + state.models.push(action.payload) + }, + updateModel: (state, action: PayloadAction) => { + const index = state.models.findIndex(m => m.id === action.payload.id) + if (index !== -1) { + state.models[index] = action.payload + } + }, + }, + extraReducers: (builder) => { + builder + .addCase(loadModels.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(loadModels.fulfilled, (state, action) => { + state.loading = false + state.models = action.payload + }) + .addCase(loadModels.rejected, (state, action) => { + state.loading = false + state.error = action.error.message || 'Failed to load models' + }) + .addCase(saveModel.fulfilled, (state, action) => { + const index = state.models.findIndex(m => m.id === action.payload.id) + if (index !== -1) { + state.models[index] = action.payload + } else { + state.models.push(action.payload) + } + }) + .addCase(deleteModel.fulfilled, (state, action) => { + state.models = state.models.filter(m => m.id !== action.payload) + if (state.activeModelId === action.payload) { + state.activeModelId = null + } + }) + }, +}) + +export const { setActiveModel, clearActiveModel, addModel, updateModel } = modelsSlice.actions +export default modelsSlice.reducer diff --git a/src/store/slices/projectSlice.ts b/src/store/slices/projectSlice.ts new file mode 100644 index 0000000..2450c24 --- /dev/null +++ b/src/store/slices/projectSlice.ts @@ -0,0 +1,174 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit' +import { db } from '@/lib/db' +import { syncToFlask, fetchFromFlask } from '@/store/middleware/flaskSync' + +export interface Project { + id: string + name: string + description?: string + createdAt: number + updatedAt: number +} + +interface ProjectState { + currentProject: Project | null + projects: Project[] + loading: boolean + error: string | null + lastSyncedAt: number | null +} + +const initialState: ProjectState = { + currentProject: null, + projects: [], + loading: false, + error: null, + lastSyncedAt: null, +} + +export const loadProjects = createAsyncThunk( + 'project/loadProjects', + async () => { + const projects = await db.getAll('projects') + return projects as Project[] + } +) + +export const loadProject = createAsyncThunk( + 'project/loadProject', + async (projectId: string) => { + const project = await db.get('projects', projectId) + return project as Project + } +) + +export const saveProject = createAsyncThunk( + 'project/saveProject', + async (project: Project) => { + await db.put('projects', project) + await syncToFlask('projects', project.id, project) + return project + } +) + +export const createProject = createAsyncThunk( + 'project/createProject', + async (projectData: { name: string; description?: string }) => { + const project: Project = { + id: `project-${Date.now()}`, + name: projectData.name, + description: projectData.description, + createdAt: Date.now(), + updatedAt: Date.now(), + } + await db.put('projects', project) + await syncToFlask('projects', project.id, project) + return project + } +) + +export const deleteProject = createAsyncThunk( + 'project/deleteProject', + async (projectId: string) => { + await db.delete('projects', projectId) + await syncToFlask('projects', projectId, null, 'delete') + return projectId + } +) + +export const syncProjectFromFlask = createAsyncThunk( + 'project/syncFromFlask', + async (projectId: string) => { + const project = await fetchFromFlask('projects', projectId) + if (project) { + await db.put('projects', project) + } + return project + } +) + +const projectSlice = createSlice({ + name: 'project', + initialState, + reducers: { + setCurrentProject: (state, action: PayloadAction) => { + state.currentProject = action.payload + }, + clearCurrentProject: (state) => { + state.currentProject = null + }, + updateProjectMetadata: (state, action: PayloadAction>) => { + if (state.currentProject) { + state.currentProject = { + ...state.currentProject, + ...action.payload, + updatedAt: Date.now(), + } + } + }, + markSynced: (state) => { + state.lastSyncedAt = Date.now() + }, + }, + extraReducers: (builder) => { + builder + .addCase(loadProjects.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(loadProjects.fulfilled, (state, action) => { + state.loading = false + state.projects = action.payload + }) + .addCase(loadProjects.rejected, (state, action) => { + state.loading = false + state.error = action.error.message || 'Failed to load projects' + }) + .addCase(loadProject.fulfilled, (state, action) => { + state.currentProject = action.payload + }) + .addCase(createProject.fulfilled, (state, action) => { + state.projects.push(action.payload) + state.currentProject = action.payload + }) + .addCase(saveProject.fulfilled, (state, action) => { + const index = state.projects.findIndex(p => p.id === action.payload.id) + if (index !== -1) { + state.projects[index] = action.payload + } + if (state.currentProject?.id === action.payload.id) { + state.currentProject = action.payload + } + state.lastSyncedAt = Date.now() + }) + .addCase(deleteProject.fulfilled, (state, action) => { + state.projects = state.projects.filter(p => p.id !== action.payload) + if (state.currentProject?.id === action.payload) { + state.currentProject = null + } + }) + .addCase(syncProjectFromFlask.fulfilled, (state, action) => { + if (action.payload) { + const index = state.projects.findIndex(p => p.id === action.payload.id) + if (index !== -1) { + state.projects[index] = action.payload + } else { + state.projects.push(action.payload) + } + if (state.currentProject?.id === action.payload.id) { + state.currentProject = action.payload + } + } + state.lastSyncedAt = Date.now() + }) + }, +}) + +export const { + setCurrentProject, + clearCurrentProject, + updateProjectMetadata, + markSynced +} = projectSlice.actions + +export default projectSlice.reducer diff --git a/src/store/slices/settingsSlice.ts b/src/store/slices/settingsSlice.ts new file mode 100644 index 0000000..4fca148 --- /dev/null +++ b/src/store/slices/settingsSlice.ts @@ -0,0 +1,69 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { syncToFlask } from '@/store/middleware/flaskSync' + +export interface Settings { + autoSave: boolean + autoSync: boolean + syncInterval: number + flaskApiUrl: string + useIndexedDB: boolean + theme: 'light' | 'dark' | 'system' +} + +interface SettingsState { + settings: Settings + loading: boolean + error: string | null +} + +const initialState: SettingsState = { + settings: { + autoSave: true, + autoSync: true, + syncInterval: 30000, + flaskApiUrl: 'http://localhost:5001', + useIndexedDB: true, + theme: 'dark', + }, + loading: false, + error: null, +} + +const settingsSlice = createSlice({ + name: 'settings', + initialState, + reducers: { + updateSettings: (state, action: PayloadAction>) => { + state.settings = { + ...state.settings, + ...action.payload, + } + syncToFlask('settings', 'app', state.settings).catch(console.error) + }, + setSettings: (state, action: PayloadAction) => { + state.settings = action.payload + }, + toggleAutoSave: (state) => { + state.settings.autoSave = !state.settings.autoSave + syncToFlask('settings', 'app', state.settings).catch(console.error) + }, + toggleAutoSync: (state) => { + state.settings.autoSync = !state.settings.autoSync + syncToFlask('settings', 'app', state.settings).catch(console.error) + }, + setSyncInterval: (state, action: PayloadAction) => { + state.settings.syncInterval = action.payload + syncToFlask('settings', 'app', state.settings).catch(console.error) + }, + }, +}) + +export const { + updateSettings, + setSettings, + toggleAutoSave, + toggleAutoSync, + setSyncInterval +} = settingsSlice.actions + +export default settingsSlice.reducer diff --git a/src/store/slices/syncSlice.ts b/src/store/slices/syncSlice.ts new file mode 100644 index 0000000..046bef8 --- /dev/null +++ b/src/store/slices/syncSlice.ts @@ -0,0 +1,175 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit' +import { + syncAllToFlask, + fetchAllFromFlask, + getFlaskStats, + clearFlaskStorage +} from '@/store/middleware/flaskSync' +import { db } from '@/lib/db' + +export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error' + +interface SyncState { + status: SyncStatus + lastSyncedAt: number | null + flaskConnected: boolean + flaskStats: { + totalKeys: number + totalSizeBytes: number + } | null + error: string | null +} + +const initialState: SyncState = { + status: 'idle', + lastSyncedAt: null, + flaskConnected: false, + flaskStats: null, + error: null, +} + +export const syncToFlaskBulk = createAsyncThunk( + 'sync/syncToFlaskBulk', + async (_, { rejectWithValue }) => { + try { + const data: Record = {} + + const files = await db.getAll('files') + const models = await db.getAll('models') + const components = await db.getAll('components') + const workflows = await db.getAll('workflows') + + files.forEach((file: any) => { + data[`files:${file.id}`] = file + }) + + models.forEach((model: any) => { + data[`models:${model.id}`] = model + }) + + components.forEach((component: any) => { + data[`components:${component.id}`] = component + }) + + workflows.forEach((workflow: any) => { + data[`workflows:${workflow.id}`] = workflow + }) + + await syncAllToFlask(data) + return Date.now() + } catch (error: any) { + return rejectWithValue(error.message) + } + } +) + +export const syncFromFlaskBulk = createAsyncThunk( + 'sync/syncFromFlaskBulk', + async (_, { rejectWithValue }) => { + try { + const data = await fetchAllFromFlask() + + for (const [key, value] of Object.entries(data)) { + const [storeName, id] = key.split(':') + + if (storeName === 'files' || + storeName === 'models' || + storeName === 'components' || + storeName === 'workflows') { + await db.put(storeName as any, value) + } + } + + return Date.now() + } catch (error: any) { + return rejectWithValue(error.message) + } + } +) + +export const checkFlaskConnection = createAsyncThunk( + 'sync/checkConnection', + async (_, { rejectWithValue }) => { + try { + const stats = await getFlaskStats() + return { + connected: true, + stats: { + totalKeys: stats.total_keys, + totalSizeBytes: stats.total_size_bytes, + }, + } + } catch (error: any) { + return rejectWithValue(error.message) + } + } +) + +export const clearFlask = createAsyncThunk( + 'sync/clearFlask', + async (_, { rejectWithValue }) => { + try { + await clearFlaskStorage() + } catch (error: any) { + return rejectWithValue(error.message) + } + } +) + +const syncSlice = createSlice({ + name: 'sync', + initialState, + reducers: { + resetSyncStatus: (state) => { + state.status = 'idle' + state.error = null + }, + setFlaskConnected: (state, action: PayloadAction) => { + state.flaskConnected = action.payload + }, + }, + extraReducers: (builder) => { + builder + .addCase(syncToFlaskBulk.pending, (state) => { + state.status = 'syncing' + state.error = null + }) + .addCase(syncToFlaskBulk.fulfilled, (state, action) => { + state.status = 'success' + state.lastSyncedAt = action.payload + }) + .addCase(syncToFlaskBulk.rejected, (state, action) => { + state.status = 'error' + state.error = action.payload as string + }) + .addCase(syncFromFlaskBulk.pending, (state) => { + state.status = 'syncing' + state.error = null + }) + .addCase(syncFromFlaskBulk.fulfilled, (state, action) => { + state.status = 'success' + state.lastSyncedAt = action.payload + }) + .addCase(syncFromFlaskBulk.rejected, (state, action) => { + state.status = 'error' + state.error = action.payload as string + }) + .addCase(checkFlaskConnection.fulfilled, (state, action) => { + state.flaskConnected = action.payload.connected + state.flaskStats = action.payload.stats + }) + .addCase(checkFlaskConnection.rejected, (state) => { + state.flaskConnected = false + state.flaskStats = null + }) + .addCase(clearFlask.fulfilled, (state) => { + state.flaskStats = { + totalKeys: 0, + totalSizeBytes: 0, + } + }) + }, +}) + +export const { resetSyncStatus, setFlaskConnected } = syncSlice.actions +export default syncSlice.reducer diff --git a/src/store/slices/themeSlice.ts b/src/store/slices/themeSlice.ts new file mode 100644 index 0000000..d3d80b7 --- /dev/null +++ b/src/store/slices/themeSlice.ts @@ -0,0 +1,104 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { syncToFlask } from '@/store/middleware/flaskSync' + +export interface Theme { + id: string + name: string + colors: ThemeColors + typography: ThemeTypography + spacing: ThemeSpacing + updatedAt: number +} + +export interface ThemeColors { + primary: string + secondary: string + accent: string + background: string + foreground: string + muted: string + destructive: string + border: string +} + +export interface ThemeTypography { + fontFamily: string + headingFamily: string + fontSize: Record + fontWeight: Record +} + +export interface ThemeSpacing { + unit: number + scale: number[] +} + +interface ThemeState { + currentTheme: Theme | null + themes: Theme[] + loading: boolean + error: string | null +} + +const initialState: ThemeState = { + currentTheme: null, + themes: [], + loading: false, + error: null, +} + +const themeSlice = createSlice({ + name: 'theme', + initialState, + reducers: { + setCurrentTheme: (state, action: PayloadAction) => { + state.currentTheme = action.payload + syncToFlask('theme', 'current', action.payload).catch(console.error) + }, + updateThemeColors: (state, action: PayloadAction>) => { + if (state.currentTheme) { + state.currentTheme.colors = { + ...state.currentTheme.colors, + ...action.payload, + } + state.currentTheme.updatedAt = Date.now() + syncToFlask('theme', 'current', state.currentTheme).catch(console.error) + } + }, + updateThemeTypography: (state, action: PayloadAction>) => { + if (state.currentTheme) { + state.currentTheme.typography = { + ...state.currentTheme.typography, + ...action.payload, + } + state.currentTheme.updatedAt = Date.now() + syncToFlask('theme', 'current', state.currentTheme).catch(console.error) + } + }, + addTheme: (state, action: PayloadAction) => { + state.themes.push(action.payload) + syncToFlask('theme', action.payload.id, action.payload).catch(console.error) + }, + deleteTheme: (state, action: PayloadAction) => { + state.themes = state.themes.filter(t => t.id !== action.payload) + if (state.currentTheme?.id === action.payload) { + state.currentTheme = null + } + syncToFlask('theme', action.payload, null, 'delete').catch(console.error) + }, + setThemes: (state, action: PayloadAction) => { + state.themes = action.payload + }, + }, +}) + +export const { + setCurrentTheme, + updateThemeColors, + updateThemeTypography, + addTheme, + deleteTheme, + setThemes +} = themeSlice.actions + +export default themeSlice.reducer diff --git a/src/store/slices/workflowsSlice.ts b/src/store/slices/workflowsSlice.ts new file mode 100644 index 0000000..9652931 --- /dev/null +++ b/src/store/slices/workflowsSlice.ts @@ -0,0 +1,127 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit' +import { db } from '@/lib/db' +import { syncToFlask, fetchFromFlask } from '@/store/middleware/flaskSync' + +export interface Workflow { + id: string + name: string + description?: string + nodes: WorkflowNode[] + edges: WorkflowEdge[] + updatedAt: number +} + +export interface WorkflowNode { + id: string + type: string + position: { x: number; y: number } + data: Record +} + +export interface WorkflowEdge { + id: string + source: string + target: string + sourceHandle?: string + targetHandle?: string +} + +interface WorkflowsState { + workflows: Workflow[] + activeWorkflowId: string | null + loading: boolean + error: string | null +} + +const initialState: WorkflowsState = { + workflows: [], + activeWorkflowId: null, + loading: false, + error: null, +} + +export const loadWorkflows = createAsyncThunk( + 'workflows/loadWorkflows', + async () => { + const workflows = await db.getAll('workflows') + return workflows as Workflow[] + } +) + +export const saveWorkflow = createAsyncThunk( + 'workflows/saveWorkflow', + async (workflow: Workflow) => { + await db.put('workflows', workflow) + await syncToFlask('workflows', workflow.id, workflow) + return workflow + } +) + +export const deleteWorkflow = createAsyncThunk( + 'workflows/deleteWorkflow', + async (workflowId: string) => { + await db.delete('workflows', workflowId) + await syncToFlask('workflows', workflowId, null, 'delete') + return workflowId + } +) + +const workflowsSlice = createSlice({ + name: 'workflows', + initialState, + reducers: { + setActiveWorkflow: (state, action: PayloadAction) => { + state.activeWorkflowId = action.payload + }, + clearActiveWorkflow: (state) => { + state.activeWorkflowId = null + }, + addWorkflow: (state, action: PayloadAction) => { + state.workflows.push(action.payload) + }, + updateWorkflow: (state, action: PayloadAction) => { + const index = state.workflows.findIndex(w => w.id === action.payload.id) + if (index !== -1) { + state.workflows[index] = action.payload + } + }, + }, + extraReducers: (builder) => { + builder + .addCase(loadWorkflows.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(loadWorkflows.fulfilled, (state, action) => { + state.loading = false + state.workflows = action.payload + }) + .addCase(loadWorkflows.rejected, (state, action) => { + state.loading = false + state.error = action.error.message || 'Failed to load workflows' + }) + .addCase(saveWorkflow.fulfilled, (state, action) => { + const index = state.workflows.findIndex(w => w.id === action.payload.id) + if (index !== -1) { + state.workflows[index] = action.payload + } else { + state.workflows.push(action.payload) + } + }) + .addCase(deleteWorkflow.fulfilled, (state, action) => { + state.workflows = state.workflows.filter(w => w.id !== action.payload) + if (state.activeWorkflowId === action.payload) { + state.activeWorkflowId = null + } + }) + }, +}) + +export const { + setActiveWorkflow, + clearActiveWorkflow, + addWorkflow, + updateWorkflow +} = workflowsSlice.actions + +export default workflowsSlice.reducer
+ Comprehensive Redux Toolkit integration with IndexedDB and Flask API synchronization +
No files yet. Create a test file to get started.
No component trees loaded yet.