mirror of
https://github.com/johndoe6345789/snippet-pastebin.git
synced 2026-04-24 13:34:55 +00:00
Generated by Spark: Implement redux
This commit is contained in:
104
PRD.md
104
PRD.md
@@ -73,43 +73,85 @@ A code snippet management application with an integrated component library showc
|
||||
- Progression: User opens settings → Selects storage backend (IndexedDB or Flask) → Configures Flask URL if needed → Tests connection → Migrates data if switching backends → Views database stats → Exports backup if needed → Can import previous backups → Loads Python templates (Euler problems or interactive programs) → Manages sample data → Can clear all data if needed
|
||||
- Success criteria: Backend switching works seamlessly, Flask connection test validates server availability, data migration preserves all snippets, shows accurate statistics, export creates valid .db file, import restores data correctly, Python templates load successfully (including interactive input examples), clear operation requires confirmation
|
||||
|
||||
## Data Persistence
|
||||
|
||||
The application supports **flexible data storage** with two backend options:
|
||||
|
||||
### Storage Backends
|
||||
|
||||
## Data Persistence
|
||||
|
||||
The application supports **flexible data storage** with two backend options:
|
||||
|
||||
### Storage Backends
|
||||
|
||||
1. **IndexedDB (Local Browser Storage) - Default**
|
||||
- Uses SQL.js (SQLite compiled to WebAssembly) for local database management
|
||||
- Primary Storage: IndexedDB - Used when available for better performance and larger storage capacity (typically 50MB+)
|
||||
- Fallback: localStorage - Used when IndexedDB is unavailable (typically 5-10MB limit)
|
||||
- Database Structure: Three tables - `snippets` (user-created snippets with namespaceId foreign key), `snippet_templates` (reusable templates), and `namespaces` (custom organizational categories with one default namespace)
|
||||
- Automatic Persistence: Database is automatically saved after every create, update, or delete operation
|
||||
- Export/Import: Users can export their entire database as a .db file for backup or transfer
|
||||
|
||||
2. **Flask Backend (Remote Server) - Optional**
|
||||
- Snippets stored on a Flask REST API server with SQLite database
|
||||
- Allows access to snippets from any device
|
||||
- Requires running the Flask backend (Docker support included)
|
||||
- RESTful API endpoints for all CRUD operations
|
||||
- Data migration tools to move snippets between IndexedDB and Flask
|
||||
|
||||
### Switching Between Backends
|
||||
|
||||
Users can switch storage backends from the Settings page:
|
||||
- Select desired backend (IndexedDB or Flask)
|
||||
- Configure Flask URL if using remote backend
|
||||
- Test connection to Flask server
|
||||
- Migrate existing snippets between backends
|
||||
- Configuration persists in localStorage
|
||||
|
||||
This approach provides:
|
||||
- Full SQL query capabilities for complex filtering and sorting
|
||||
- Choice between local-only or remote storage
|
||||
- Reliable persistence across browser sessions
|
||||
- Easy backup and restore functionality
|
||||
- Protection against localStorage quota exceeded errors
|
||||
- Multi-device access when using Flask backend
|
||||
- Export/Import: Users can export their entire database as a .db file for backup or transfer
|
||||
|
||||
2. **Flask Backend (Remote Server) - Optional**
|
||||
- Snippets stored on a Flask REST API server with SQLite database
|
||||
- Allows access to snippets from any device
|
||||
- Requires running the Flask backend (Docker support included)
|
||||
- RESTful API endpoints for all CRUD operations
|
||||
- Data migration tools to move snippets between IndexedDB and Flask
|
||||
|
||||
### Switching Between Backends
|
||||
|
||||
Users can switch storage backends from the Settings page:
|
||||
- Select desired backend (IndexedDB or Flask)
|
||||
- Configure Flask URL if using remote backend
|
||||
- Test connection to Flask server
|
||||
- Migrate existing snippets between backends
|
||||
- Configuration persists in localStorage
|
||||
|
||||
This approach provides:
|
||||
- Full SQL query capabilities for complex filtering and sorting
|
||||
- Choice between local-only or remote storage
|
||||
- Reliable persistence across browser sessions
|
||||
- Easy backup and restore functionality
|
||||
- Protection against localStorage quota exceeded errors
|
||||
- Multi-device access when using Flask backend
|
||||
|
||||
## State Management Architecture
|
||||
|
||||
The application now uses **Redux Toolkit** for centralized state management, replacing the previous component-level state approach.
|
||||
|
||||
### Redux Store Structure
|
||||
|
||||
**Three Main Slices:**
|
||||
1. **Snippets Slice** (`snippetsSlice.ts`)
|
||||
- Manages all snippet data (items, loading states, errors)
|
||||
- Handles selection mode and selected snippet IDs
|
||||
- Async thunks for CRUD operations (create, read, update, delete, bulk move)
|
||||
- Integrates with database layer (IndexedDB or Flask)
|
||||
|
||||
2. **Namespaces Slice** (`namespacesSlice.ts`)
|
||||
- Manages namespace data and selected namespace
|
||||
- Async thunks for namespace operations (fetch, create, delete)
|
||||
- Ensures default namespace always exists
|
||||
- Handles automatic namespace selection on load
|
||||
|
||||
3. **UI Slice** (`uiSlice.ts`)
|
||||
- Controls dialog states (snippet dialog, viewer dialog)
|
||||
- Manages currently editing/viewing snippets
|
||||
- Handles search query state
|
||||
- Pure synchronous actions for instant UI updates
|
||||
|
||||
### Key Features
|
||||
- **Centralized State**: All application state in one predictable location
|
||||
- **TypeScript Integration**: Fully typed state and actions with type inference
|
||||
- **Memoized Selectors**: Efficient computed state using Reselect
|
||||
- **Async Actions**: Built-in loading/error states for all database operations
|
||||
- **Custom Hooks**: `useAppDispatch` and `useAppSelector` for type-safe Redux access
|
||||
- **DevTools Support**: Redux DevTools integration for time-travel debugging
|
||||
- **Immutable Updates**: Automatic immutability with Immer (via Redux Toolkit)
|
||||
|
||||
### Benefits Over Previous Approach
|
||||
- **Predictable State Flow**: Actions → Reducers → State → UI (unidirectional)
|
||||
- **Easier Debugging**: Redux DevTools shows every state change with time-travel
|
||||
- **Better Testing**: Pure functions and isolated state make testing straightforward
|
||||
- **Scalability**: Easy to add new features without prop drilling
|
||||
- **Performance**: Memoized selectors prevent unnecessary re-renders
|
||||
- **Developer Experience**: TypeScript autocomplete for all state and actions
|
||||
|
||||
## Edge Case Handling
|
||||
- **No Search Results**: Friendly message encouraging users to refine their search
|
||||
|
||||
350
REDUX-GUIDE.md
Normal file
350
REDUX-GUIDE.md
Normal file
@@ -0,0 +1,350 @@
|
||||
# Redux Implementation Guide
|
||||
|
||||
This document explains the Redux state management architecture implemented in the CodeSnippet application.
|
||||
|
||||
## Overview
|
||||
|
||||
The application uses **Redux Toolkit** (RTK) for centralized state management. Redux provides a predictable state container that makes the application easier to understand, debug, and test.
|
||||
|
||||
## Store Structure
|
||||
|
||||
```
|
||||
src/store/
|
||||
├── index.ts # Store configuration
|
||||
├── hooks.ts # Typed Redux hooks
|
||||
├── selectors.ts # Memoized selectors
|
||||
└── slices/
|
||||
├── snippetsSlice.ts # Snippet state & actions
|
||||
├── namespacesSlice.ts # Namespace state & actions
|
||||
└── uiSlice.ts # UI state & actions
|
||||
```
|
||||
|
||||
## State Slices
|
||||
|
||||
### 1. Snippets Slice
|
||||
|
||||
**Location:** `src/store/slices/snippetsSlice.ts`
|
||||
|
||||
**State:**
|
||||
```typescript
|
||||
{
|
||||
items: Snippet[] // All snippets
|
||||
loading: boolean // Loading state for async operations
|
||||
error: string | null // Error message if operations fail
|
||||
selectedIds: Set<string> // IDs of selected snippets (for bulk operations)
|
||||
selectionMode: boolean // Whether selection mode is active
|
||||
}
|
||||
```
|
||||
|
||||
**Async Actions (Thunks):**
|
||||
- `fetchAllSnippets()` - Load all snippets from database
|
||||
- `fetchSnippetsByNamespace(namespaceId)` - Load snippets for specific namespace
|
||||
- `createSnippet(snippetData)` - Create new snippet
|
||||
- `updateSnippet(snippet)` - Update existing snippet
|
||||
- `deleteSnippet(id)` - Delete snippet by ID
|
||||
- `bulkMoveSnippets({ snippetIds, targetNamespaceId })` - Move multiple snippets to namespace
|
||||
|
||||
**Sync Actions:**
|
||||
- `toggleSelectionMode()` - Enable/disable selection mode
|
||||
- `toggleSnippetSelection(id)` - Toggle selection of specific snippet
|
||||
- `clearSelection()` - Clear all selections
|
||||
- `selectAllSnippets()` - Select all current snippets
|
||||
|
||||
### 2. Namespaces Slice
|
||||
|
||||
**Location:** `src/store/slices/namespacesSlice.ts`
|
||||
|
||||
**State:**
|
||||
```typescript
|
||||
{
|
||||
items: Namespace[] // All namespaces
|
||||
selectedId: string | null // Currently selected namespace ID
|
||||
loading: boolean // Loading state
|
||||
error: string | null // Error message
|
||||
}
|
||||
```
|
||||
|
||||
**Async Actions:**
|
||||
- `fetchNamespaces()` - Load all namespaces and ensure default exists
|
||||
- `createNamespace(name)` - Create new namespace
|
||||
- `deleteNamespace(id)` - Delete namespace (moves snippets to default)
|
||||
|
||||
**Sync Actions:**
|
||||
- `setSelectedNamespace(id)` - Switch to different namespace
|
||||
|
||||
### 3. UI Slice
|
||||
|
||||
**Location:** `src/store/slices/uiSlice.ts`
|
||||
|
||||
**State:**
|
||||
```typescript
|
||||
{
|
||||
dialogOpen: boolean // Snippet editor dialog open/closed
|
||||
viewerOpen: boolean // Snippet viewer dialog open/closed
|
||||
editingSnippet: Snippet | null // Snippet being edited (null for new)
|
||||
viewingSnippet: Snippet | null // Snippet being viewed
|
||||
searchQuery: string // Current search text
|
||||
}
|
||||
```
|
||||
|
||||
**Sync Actions:**
|
||||
- `openDialog(snippet)` - Open editor dialog (snippet = null for new, snippet = existing for edit)
|
||||
- `closeDialog()` - Close editor dialog
|
||||
- `openViewer(snippet)` - Open viewer dialog with snippet
|
||||
- `closeViewer()` - Close viewer dialog
|
||||
- `setSearchQuery(query)` - Update search query
|
||||
|
||||
## Selectors
|
||||
|
||||
**Location:** `src/store/selectors.ts`
|
||||
|
||||
Selectors provide optimized access to state with memoization to prevent unnecessary re-renders.
|
||||
|
||||
**Basic Selectors:**
|
||||
```typescript
|
||||
selectSnippets(state) // Get all snippets
|
||||
selectSnippetsLoading(state) // Get loading state
|
||||
selectSelectionMode(state) // Get selection mode
|
||||
selectSelectedIds(state) // Get selected snippet IDs
|
||||
selectNamespaces(state) // Get all namespaces
|
||||
selectSelectedNamespaceId(state) // Get selected namespace ID
|
||||
selectSearchQuery(state) // Get search query
|
||||
selectDialogOpen(state) // Get dialog open state
|
||||
selectViewerOpen(state) // Get viewer open state
|
||||
selectEditingSnippet(state) // Get editing snippet
|
||||
selectViewingSnippet(state) // Get viewing snippet
|
||||
```
|
||||
|
||||
**Computed Selectors (Memoized):**
|
||||
```typescript
|
||||
selectSelectedNamespace(state) // Get full selected namespace object
|
||||
selectFilteredSnippets(state) // Get snippets filtered by search query
|
||||
selectSelectedSnippets(state) // Get full snippet objects for selected IDs
|
||||
```
|
||||
|
||||
## Custom Hooks
|
||||
|
||||
**Location:** `src/store/hooks.ts`
|
||||
|
||||
Type-safe hooks for accessing Redux:
|
||||
|
||||
```typescript
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks'
|
||||
|
||||
// In components:
|
||||
const dispatch = useAppDispatch() // Typed dispatch
|
||||
const snippets = useAppSelector(selectSnippets) // Typed selector
|
||||
```
|
||||
|
||||
## Usage Example
|
||||
|
||||
```typescript
|
||||
import { useEffect } from 'react'
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks'
|
||||
import {
|
||||
fetchSnippetsByNamespace,
|
||||
createSnippet,
|
||||
deleteSnippet,
|
||||
} from '@/store/slices/snippetsSlice'
|
||||
import {
|
||||
selectFilteredSnippets,
|
||||
selectSnippetsLoading,
|
||||
selectSearchQuery,
|
||||
} from '@/store/selectors'
|
||||
import { setSearchQuery } from '@/store/slices/uiSlice'
|
||||
|
||||
function MyComponent() {
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
// Select state
|
||||
const snippets = useAppSelector(selectFilteredSnippets)
|
||||
const loading = useAppSelector(selectSnippetsLoading)
|
||||
const searchQuery = useAppSelector(selectSearchQuery)
|
||||
|
||||
// Load snippets on mount
|
||||
useEffect(() => {
|
||||
dispatch(fetchSnippetsByNamespace('default-namespace-id'))
|
||||
}, [dispatch])
|
||||
|
||||
// Handle search
|
||||
const handleSearch = (query: string) => {
|
||||
dispatch(setSearchQuery(query))
|
||||
}
|
||||
|
||||
// Create snippet
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
await dispatch(createSnippet({
|
||||
title: 'New Snippet',
|
||||
description: 'Description',
|
||||
code: 'console.log("Hello")',
|
||||
language: 'javascript',
|
||||
category: 'JavaScript',
|
||||
})).unwrap()
|
||||
|
||||
console.log('Snippet created successfully')
|
||||
} catch (error) {
|
||||
console.error('Failed to create snippet:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete snippet
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await dispatch(deleteSnippet(id)).unwrap()
|
||||
console.log('Snippet deleted')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div>Loading...</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
placeholder="Search snippets..."
|
||||
/>
|
||||
|
||||
<button onClick={handleCreate}>Create Snippet</button>
|
||||
|
||||
{snippets.map(snippet => (
|
||||
<div key={snippet.id}>
|
||||
<h3>{snippet.title}</h3>
|
||||
<button onClick={() => handleDelete(snippet.id)}>Delete</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
### 1. Predictable State Updates
|
||||
- All state changes go through reducers
|
||||
- Same input always produces same output
|
||||
- Easy to trace how state changes over time
|
||||
|
||||
### 2. Debugging
|
||||
- Redux DevTools extension shows:
|
||||
- Every action dispatched
|
||||
- State before and after each action
|
||||
- Time-travel debugging (undo/redo actions)
|
||||
- Action history and diffs
|
||||
|
||||
### 3. Testing
|
||||
```typescript
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import snippetsReducer, { createSnippet } from './slices/snippetsSlice'
|
||||
|
||||
describe('Snippets Slice', () => {
|
||||
it('should handle createSnippet', async () => {
|
||||
const store = configureStore({
|
||||
reducer: { snippets: snippetsReducer }
|
||||
})
|
||||
|
||||
await store.dispatch(createSnippet({
|
||||
title: 'Test',
|
||||
// ... other fields
|
||||
}))
|
||||
|
||||
const state = store.getState()
|
||||
expect(state.snippets.items).toHaveLength(1)
|
||||
expect(state.snippets.items[0].title).toBe('Test')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 4. Performance
|
||||
- Memoized selectors prevent unnecessary re-renders
|
||||
- Only components using changed state re-render
|
||||
- Efficient state updates with Immer
|
||||
|
||||
### 5. Type Safety
|
||||
- Full TypeScript integration
|
||||
- IntelliSense for all actions and state
|
||||
- Compile-time error catching
|
||||
|
||||
## Migration from Component State
|
||||
|
||||
**Before (Component State):**
|
||||
```typescript
|
||||
function SnippetManager() {
|
||||
const [snippets, setSnippets] = useState<Snippet[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const loadSnippets = async () => {
|
||||
setLoading(true)
|
||||
const data = await getAllSnippets()
|
||||
setSnippets(data)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// Pass down through props...
|
||||
}
|
||||
```
|
||||
|
||||
**After (Redux):**
|
||||
```typescript
|
||||
function SnippetManager() {
|
||||
const dispatch = useAppDispatch()
|
||||
const snippets = useAppSelector(selectSnippets)
|
||||
const loading = useAppSelector(selectSnippetsLoading)
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchAllSnippets())
|
||||
}, [dispatch])
|
||||
|
||||
// State available anywhere via hooks - no prop drilling
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Async Thunks for Side Effects**
|
||||
- Database calls
|
||||
- API requests
|
||||
- Any async operations
|
||||
|
||||
2. **Keep Slices Focused**
|
||||
- Each slice manages related state
|
||||
- Don't create mega-slices
|
||||
|
||||
3. **Use Selectors**
|
||||
- Don't access state directly
|
||||
- Use memoized selectors for computed values
|
||||
- Prevents unnecessary re-renders
|
||||
|
||||
4. **Handle Loading States**
|
||||
- Show loading indicators during async operations
|
||||
- Handle errors gracefully
|
||||
- Use pending/fulfilled/rejected cases
|
||||
|
||||
5. **Type Everything**
|
||||
- Define state interfaces
|
||||
- Type action payloads
|
||||
- Use typed hooks
|
||||
|
||||
## Redux DevTools
|
||||
|
||||
Install the [Redux DevTools Extension](https://github.com/reduxjs/redux-devtools):
|
||||
|
||||
- **Chrome:** [Chrome Web Store](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd)
|
||||
- **Firefox:** [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/reduxdevtools/)
|
||||
|
||||
Features:
|
||||
- Inspect every action and state change
|
||||
- Time-travel debugging
|
||||
- Action replay
|
||||
- State diff viewer
|
||||
- Export/import state
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Redux Toolkit Documentation](https://redux-toolkit.js.org/)
|
||||
- [Redux Style Guide](https://redux.js.org/style-guide/)
|
||||
- [Using Redux with TypeScript](https://redux.js.org/usage/usage-with-typescript)
|
||||
- [Redux DevTools Guide](https://github.com/reduxjs/redux-devtools)
|
||||
94
package-lock.json
generated
94
package-lock.json
generated
@@ -42,6 +42,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",
|
||||
@@ -64,6 +65,7 @@
|
||||
"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",
|
||||
"recharts": "^2.15.1",
|
||||
@@ -3121,6 +3123,32 @@
|
||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
|
||||
@@ -3595,6 +3623,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
@@ -4465,6 +4499,12 @@
|
||||
"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",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz",
|
||||
@@ -7267,6 +7307,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"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/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -8665,6 +8715,29 @@
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"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",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
@@ -8872,6 +8945,27 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"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-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": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
|
||||
@@ -46,6 +46,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",
|
||||
@@ -68,6 +69,7 @@
|
||||
"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",
|
||||
"recharts": "^2.15.1",
|
||||
|
||||
427
src/components/SnippetManagerRedux.tsx
Normal file
427
src/components/SnippetManagerRedux.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
import { useEffect, useMemo, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Plus, MagnifyingGlass, CaretDown, CheckSquare, FolderOpen, X } from '@phosphor-icons/react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { SnippetCard } from '@/components/SnippetCard'
|
||||
import { SnippetDialog } from '@/components/SnippetDialog'
|
||||
import { SnippetViewer } from '@/components/SnippetViewer'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { NamespaceSelector } from '@/components/NamespaceSelector'
|
||||
import { Snippet, SnippetTemplate } from '@/lib/types'
|
||||
import { toast } from 'sonner'
|
||||
import { strings } from '@/lib/config'
|
||||
import templatesData from '@/data/templates.json'
|
||||
import { seedDatabase, syncTemplatesFromJSON } from '@/lib/db'
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks'
|
||||
import {
|
||||
fetchSnippetsByNamespace,
|
||||
createSnippet,
|
||||
updateSnippet,
|
||||
deleteSnippet,
|
||||
toggleSelectionMode,
|
||||
toggleSnippetSelection,
|
||||
selectAllSnippets as selectAllSnippetsAction,
|
||||
clearSelection,
|
||||
bulkMoveSnippets,
|
||||
} from '@/store/slices/snippetsSlice'
|
||||
import {
|
||||
fetchNamespaces,
|
||||
setSelectedNamespace,
|
||||
} from '@/store/slices/namespacesSlice'
|
||||
import {
|
||||
openDialog,
|
||||
closeDialog,
|
||||
openViewer,
|
||||
closeViewer,
|
||||
setSearchQuery,
|
||||
} from '@/store/slices/uiSlice'
|
||||
import {
|
||||
selectFilteredSnippets,
|
||||
selectSnippetsLoading,
|
||||
selectSelectionMode,
|
||||
selectSelectedIds,
|
||||
selectNamespaces,
|
||||
selectSelectedNamespaceId,
|
||||
selectDialogOpen,
|
||||
selectViewerOpen,
|
||||
selectEditingSnippet,
|
||||
selectViewingSnippet,
|
||||
selectSearchQuery,
|
||||
selectSnippets,
|
||||
} from '@/store/selectors'
|
||||
|
||||
const templates = templatesData as SnippetTemplate[]
|
||||
|
||||
export function SnippetManagerRedux() {
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const snippets = useAppSelector(selectSnippets)
|
||||
const filteredSnippets = useAppSelector(selectFilteredSnippets)
|
||||
const loading = useAppSelector(selectSnippetsLoading)
|
||||
const selectionMode = useAppSelector(selectSelectionMode)
|
||||
const selectedIds = useAppSelector(selectSelectedIds)
|
||||
const namespaces = useAppSelector(selectNamespaces)
|
||||
const selectedNamespaceId = useAppSelector(selectSelectedNamespaceId)
|
||||
const dialogOpen = useAppSelector(selectDialogOpen)
|
||||
const viewerOpen = useAppSelector(selectViewerOpen)
|
||||
const editingSnippet = useAppSelector(selectEditingSnippet)
|
||||
const viewingSnippet = useAppSelector(selectViewingSnippet)
|
||||
const searchQuery = useAppSelector(selectSearchQuery)
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
await seedDatabase()
|
||||
await syncTemplatesFromJSON(templates)
|
||||
await dispatch(fetchNamespaces()).unwrap()
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error)
|
||||
toast.error('Failed to load data')
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
}, [dispatch])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedNamespaceId) {
|
||||
dispatch(fetchSnippetsByNamespace(selectedNamespaceId))
|
||||
}
|
||||
}, [dispatch, selectedNamespaceId])
|
||||
|
||||
const handleSaveSnippet = useCallback(async (snippetData: Omit<Snippet, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
try {
|
||||
if (editingSnippet?.id) {
|
||||
await dispatch(updateSnippet({ ...editingSnippet, ...snippetData })).unwrap()
|
||||
toast.success(strings.toast.snippetUpdated)
|
||||
} else {
|
||||
await dispatch(createSnippet({
|
||||
...snippetData,
|
||||
namespaceId: selectedNamespaceId || undefined,
|
||||
})).unwrap()
|
||||
toast.success(strings.toast.snippetCreated)
|
||||
}
|
||||
dispatch(closeDialog())
|
||||
} catch (error) {
|
||||
console.error('Failed to save snippet:', error)
|
||||
toast.error('Failed to save snippet')
|
||||
}
|
||||
}, [dispatch, editingSnippet, selectedNamespaceId])
|
||||
|
||||
const handleEditSnippet = useCallback((snippet: Snippet) => {
|
||||
dispatch(openDialog(snippet))
|
||||
}, [dispatch])
|
||||
|
||||
const handleDeleteSnippet = useCallback(async (id: string) => {
|
||||
try {
|
||||
await dispatch(deleteSnippet(id)).unwrap()
|
||||
toast.success(strings.toast.snippetDeleted)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete snippet:', error)
|
||||
toast.error('Failed to delete snippet')
|
||||
}
|
||||
}, [dispatch])
|
||||
|
||||
const handleCopyCode = useCallback((code: string) => {
|
||||
navigator.clipboard.writeText(code)
|
||||
toast.success(strings.toast.codeCopied)
|
||||
}, [])
|
||||
|
||||
const handleViewSnippet = useCallback((snippet: Snippet) => {
|
||||
dispatch(openViewer(snippet))
|
||||
}, [dispatch])
|
||||
|
||||
const handleCreateNew = useCallback(() => {
|
||||
dispatch(openDialog(null))
|
||||
}, [dispatch])
|
||||
|
||||
const handleCreateFromTemplate = useCallback((templateId: string) => {
|
||||
const template = templates.find((t) => t.id === templateId)
|
||||
if (!template) return
|
||||
|
||||
const templateSnippet = {
|
||||
id: '',
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
language: template.language,
|
||||
code: template.code,
|
||||
category: template.category,
|
||||
hasPreview: template.hasPreview,
|
||||
functionName: template.functionName,
|
||||
inputParameters: template.inputParameters,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
} as Snippet
|
||||
|
||||
dispatch(openDialog(templateSnippet))
|
||||
}, [dispatch])
|
||||
|
||||
const handleDialogClose = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
dispatch(closeDialog())
|
||||
}
|
||||
}, [dispatch])
|
||||
|
||||
const handleToggleSelectionMode = useCallback(() => {
|
||||
dispatch(toggleSelectionMode())
|
||||
}, [dispatch])
|
||||
|
||||
const handleToggleSnippetSelection = useCallback((snippetId: string) => {
|
||||
dispatch(toggleSnippetSelection(snippetId))
|
||||
}, [dispatch])
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (selectedIds.size === filteredSnippets.length) {
|
||||
dispatch(clearSelection())
|
||||
} else {
|
||||
dispatch(selectAllSnippetsAction())
|
||||
}
|
||||
}, [dispatch, filteredSnippets.length, selectedIds.size])
|
||||
|
||||
const handleBulkMove = useCallback(async (targetNamespaceId: string) => {
|
||||
if (selectedIds.size === 0) {
|
||||
toast.error('No snippets selected')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await dispatch(bulkMoveSnippets({
|
||||
snippetIds: Array.from(selectedIds),
|
||||
targetNamespaceId
|
||||
})).unwrap()
|
||||
|
||||
const targetNamespace = namespaces.find(n => n.id === targetNamespaceId)
|
||||
toast.success(`Moved ${selectedIds.size} snippet${selectedIds.size > 1 ? 's' : ''} to ${targetNamespace?.name || 'namespace'}`)
|
||||
|
||||
if (selectedNamespaceId) {
|
||||
dispatch(fetchSnippetsByNamespace(selectedNamespaceId))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to bulk move snippets:', error)
|
||||
toast.error('Failed to move snippets')
|
||||
}
|
||||
}, [dispatch, selectedIds, namespaces, selectedNamespaceId])
|
||||
|
||||
const handleNamespaceChange = useCallback((namespaceId: string | null) => {
|
||||
if (namespaceId) {
|
||||
dispatch(setSelectedNamespace(namespaceId))
|
||||
}
|
||||
}, [dispatch])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-muted-foreground">Loading snippets...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (snippets.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<NamespaceSelector
|
||||
selectedNamespaceId={selectedNamespaceId}
|
||||
onNamespaceChange={handleNamespaceChange}
|
||||
/>
|
||||
</div>
|
||||
<EmptyState
|
||||
onCreateClick={handleCreateNew}
|
||||
onCreateFromTemplate={handleCreateFromTemplate}
|
||||
/>
|
||||
<SnippetDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={handleDialogClose}
|
||||
onSave={handleSaveSnippet}
|
||||
editingSnippet={editingSnippet}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<NamespaceSelector
|
||||
selectedNamespaceId={selectedNamespaceId}
|
||||
onNamespaceChange={handleNamespaceChange}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<div className="relative flex-1 w-full sm:max-w-md">
|
||||
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={strings.app.search.placeholder}
|
||||
value={searchQuery}
|
||||
onChange={(e) => dispatch(setSearchQuery(e.target.value))}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<Button
|
||||
variant={selectionMode ? "default" : "outline"}
|
||||
onClick={handleToggleSelectionMode}
|
||||
className="gap-2"
|
||||
>
|
||||
{selectionMode ? (
|
||||
<>
|
||||
<X weight="bold" />
|
||||
Cancel
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckSquare weight="bold" />
|
||||
Select
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="gap-2 w-full sm:w-auto">
|
||||
<Plus weight="bold" />
|
||||
{strings.app.header.newSnippetButton}
|
||||
<CaretDown weight="bold" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-72 max-h-[500px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={handleCreateNew}>
|
||||
<Plus className="mr-2 h-4 w-4" weight="bold" />
|
||||
Blank Snippet
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>React Components</DropdownMenuLabel>
|
||||
{templates.filter((t) => t.category === 'react').map((template) => (
|
||||
<DropdownMenuItem
|
||||
key={template.id}
|
||||
onClick={() => handleCreateFromTemplate(template.id)}
|
||||
>
|
||||
<div className="flex flex-col gap-1 py-1">
|
||||
<span className="font-medium">{template.title}</span>
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">
|
||||
{template.description}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Python Scripts</DropdownMenuLabel>
|
||||
{templates.filter((t) => t.category === 'python').map((template) => (
|
||||
<DropdownMenuItem
|
||||
key={template.id}
|
||||
onClick={() => handleCreateFromTemplate(template.id)}
|
||||
>
|
||||
<div className="flex flex-col gap-1 py-1">
|
||||
<span className="font-medium">{template.title}</span>
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">
|
||||
{template.description}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>JavaScript Utils</DropdownMenuLabel>
|
||||
{templates.filter((t) => t.category === 'javascript').map((template) => (
|
||||
<DropdownMenuItem
|
||||
key={template.id}
|
||||
onClick={() => handleCreateFromTemplate(template.id)}
|
||||
>
|
||||
<div className="flex flex-col gap-1 py-1">
|
||||
<span className="font-medium">{template.title}</span>
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">
|
||||
{template.description}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectionMode && (
|
||||
<div className="flex items-center gap-2 p-4 bg-muted/50 rounded-lg">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
{selectedIds.size === filteredSnippets.length ? 'Deselect All' : 'Select All'}
|
||||
</Button>
|
||||
{selectedIds.size > 0 && (
|
||||
<>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<FolderOpen weight="bold" className="h-4 w-4" />
|
||||
Move to...
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{namespaces.map((namespace) => (
|
||||
<DropdownMenuItem
|
||||
key={namespace.id}
|
||||
onClick={() => handleBulkMove(namespace.id)}
|
||||
disabled={namespace.id === selectedNamespaceId}
|
||||
>
|
||||
{namespace.name} {namespace.isDefault && '(Default)'}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredSnippets.length === 0 && searchQuery && (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-muted-foreground">No snippets found matching "{searchQuery}"</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredSnippets.map((snippet) => (
|
||||
<SnippetCard
|
||||
key={snippet.id}
|
||||
snippet={snippet}
|
||||
onView={handleViewSnippet}
|
||||
onEdit={handleEditSnippet}
|
||||
onDelete={handleDeleteSnippet}
|
||||
onCopy={handleCopyCode}
|
||||
selectionMode={selectionMode}
|
||||
isSelected={selectedIds.has(snippet.id)}
|
||||
onToggleSelect={handleToggleSnippetSelection}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SnippetDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={handleDialogClose}
|
||||
onSave={handleSaveSnippet}
|
||||
editingSnippet={editingSnippet}
|
||||
/>
|
||||
|
||||
<SnippetViewer
|
||||
open={viewerOpen}
|
||||
onOpenChange={(open) => !open && dispatch(closeViewer())}
|
||||
snippet={viewingSnippet}
|
||||
onEdit={handleEditSnippet}
|
||||
onCopy={handleCopyCode}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
src/main.tsx
18
src/main.tsx
@@ -1,8 +1,10 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { Provider } from 'react-redux'
|
||||
import "@github/spark/spark"
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { loadStorageConfig } from '@/lib/storage'
|
||||
import { store } from '@/store'
|
||||
|
||||
import App from './App.tsx'
|
||||
import { ErrorFallback } from './ErrorFallback.tsx'
|
||||
@@ -21,11 +23,13 @@ const logErrorToConsole = (error: Error, info: { componentStack?: string }) => {
|
||||
};
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<ErrorBoundary
|
||||
FallbackComponent={ErrorFallback}
|
||||
onError={logErrorToConsole}
|
||||
>
|
||||
<App />
|
||||
<Toaster />
|
||||
</ErrorBoundary>
|
||||
<Provider store={store}>
|
||||
<ErrorBoundary
|
||||
FallbackComponent={ErrorFallback}
|
||||
onError={logErrorToConsole}
|
||||
>
|
||||
<App />
|
||||
<Toaster />
|
||||
</ErrorBoundary>
|
||||
</Provider>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { SnippetManager } from '@/components/SnippetManager'
|
||||
import { SnippetManagerRedux } from '@/components/SnippetManagerRedux'
|
||||
|
||||
export function HomePage() {
|
||||
return (
|
||||
@@ -12,7 +12,7 @@ export function HomePage() {
|
||||
<h2 className="text-3xl font-bold tracking-tight mb-2">My Snippets</h2>
|
||||
<p className="text-muted-foreground">Save, organize, and share your code snippets</p>
|
||||
</div>
|
||||
<SnippetManager />
|
||||
<SnippetManagerRedux />
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
7
src/store/exports.ts
Normal file
7
src/store/exports.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { store } from './index'
|
||||
export type { RootState, AppDispatch } from './index'
|
||||
export { useAppDispatch, useAppSelector } from './hooks'
|
||||
export * from './selectors'
|
||||
export * from './slices/snippetsSlice'
|
||||
export * from './slices/namespacesSlice'
|
||||
export * from './slices/uiSlice'
|
||||
5
src/store/hooks.ts
Normal file
5
src/store/hooks.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import type { RootState, AppDispatch } from './index'
|
||||
|
||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
|
||||
export const useAppSelector = useSelector.withTypes<RootState>()
|
||||
19
src/store/index.ts
Normal file
19
src/store/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import snippetsReducer from './slices/snippetsSlice'
|
||||
import namespacesReducer from './slices/namespacesSlice'
|
||||
import uiReducer from './slices/uiSlice'
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
snippets: snippetsReducer,
|
||||
namespaces: namespacesReducer,
|
||||
ui: uiReducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: false,
|
||||
}),
|
||||
})
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
43
src/store/selectors.ts
Normal file
43
src/store/selectors.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createSelector } from '@reduxjs/toolkit'
|
||||
import { RootState } from './index'
|
||||
|
||||
export const selectSnippets = (state: RootState) => state.snippets.items
|
||||
export const selectSnippetsLoading = (state: RootState) => state.snippets.loading
|
||||
export const selectSelectionMode = (state: RootState) => state.snippets.selectionMode
|
||||
export const selectSelectedIds = (state: RootState) => state.snippets.selectedIds
|
||||
|
||||
export const selectNamespaces = (state: RootState) => state.namespaces.items
|
||||
export const selectSelectedNamespaceId = (state: RootState) => state.namespaces.selectedId
|
||||
export const selectNamespacesLoading = (state: RootState) => state.namespaces.loading
|
||||
|
||||
export const selectSearchQuery = (state: RootState) => state.ui.searchQuery
|
||||
export const selectDialogOpen = (state: RootState) => state.ui.dialogOpen
|
||||
export const selectViewerOpen = (state: RootState) => state.ui.viewerOpen
|
||||
export const selectEditingSnippet = (state: RootState) => state.ui.editingSnippet
|
||||
export const selectViewingSnippet = (state: RootState) => state.ui.viewingSnippet
|
||||
|
||||
export const selectSelectedNamespace = createSelector(
|
||||
[selectNamespaces, selectSelectedNamespaceId],
|
||||
(namespaces, selectedId) => namespaces.find(n => n.id === selectedId)
|
||||
)
|
||||
|
||||
export const selectFilteredSnippets = createSelector(
|
||||
[selectSnippets, selectSearchQuery],
|
||||
(snippets, query) => {
|
||||
if (!query.trim()) return snippets
|
||||
|
||||
const lowerQuery = query.toLowerCase()
|
||||
return snippets.filter(
|
||||
(snippet) =>
|
||||
snippet.title.toLowerCase().includes(lowerQuery) ||
|
||||
snippet.description.toLowerCase().includes(lowerQuery) ||
|
||||
snippet.language.toLowerCase().includes(lowerQuery) ||
|
||||
snippet.code.toLowerCase().includes(lowerQuery)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export const selectSelectedSnippets = createSelector(
|
||||
[selectSnippets, selectSelectedIds],
|
||||
(snippets, selectedIds) => snippets.filter(s => selectedIds.has(s.id))
|
||||
)
|
||||
88
src/store/slices/namespacesSlice.ts
Normal file
88
src/store/slices/namespacesSlice.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'
|
||||
import { Namespace } from '@/lib/types'
|
||||
import {
|
||||
getAllNamespaces,
|
||||
createNamespace as createNamespaceDB,
|
||||
deleteNamespace as deleteNamespaceDB,
|
||||
ensureDefaultNamespace,
|
||||
} from '@/lib/db'
|
||||
|
||||
interface NamespacesState {
|
||||
items: Namespace[]
|
||||
selectedId: string | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const initialState: NamespacesState = {
|
||||
items: [],
|
||||
selectedId: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
export const fetchNamespaces = createAsyncThunk(
|
||||
'namespaces/fetchAll',
|
||||
async () => {
|
||||
await ensureDefaultNamespace()
|
||||
return await getAllNamespaces()
|
||||
}
|
||||
)
|
||||
|
||||
export const createNamespace = createAsyncThunk(
|
||||
'namespaces/create',
|
||||
async (name: string) => {
|
||||
return await createNamespaceDB(name)
|
||||
}
|
||||
)
|
||||
|
||||
export const deleteNamespace = createAsyncThunk(
|
||||
'namespaces/delete',
|
||||
async (id: string) => {
|
||||
await deleteNamespaceDB(id)
|
||||
return id
|
||||
}
|
||||
)
|
||||
|
||||
const namespacesSlice = createSlice({
|
||||
name: 'namespaces',
|
||||
initialState,
|
||||
reducers: {
|
||||
setSelectedNamespace: (state, action: PayloadAction<string>) => {
|
||||
state.selectedId = action.payload
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchNamespaces.pending, (state) => {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
})
|
||||
.addCase(fetchNamespaces.fulfilled, (state, action) => {
|
||||
state.loading = false
|
||||
state.items = action.payload
|
||||
if (!state.selectedId && action.payload.length > 0) {
|
||||
const defaultNamespace = action.payload.find(n => n.isDefault)
|
||||
state.selectedId = defaultNamespace?.id || action.payload[0].id
|
||||
}
|
||||
})
|
||||
.addCase(fetchNamespaces.rejected, (state, action) => {
|
||||
state.loading = false
|
||||
state.error = action.error.message || 'Failed to fetch namespaces'
|
||||
})
|
||||
.addCase(createNamespace.fulfilled, (state, action) => {
|
||||
state.items.push(action.payload)
|
||||
})
|
||||
.addCase(deleteNamespace.fulfilled, (state, action) => {
|
||||
state.items = state.items.filter(n => n.id !== action.payload)
|
||||
if (state.selectedId === action.payload) {
|
||||
const defaultNamespace = state.items.find(n => n.isDefault)
|
||||
state.selectedId = defaultNamespace?.id || state.items[0]?.id || null
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const { setSelectedNamespace } = namespacesSlice.actions
|
||||
|
||||
export default namespacesSlice.reducer
|
||||
168
src/store/slices/snippetsSlice.ts
Normal file
168
src/store/slices/snippetsSlice.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'
|
||||
import { Snippet } from '@/lib/types'
|
||||
import {
|
||||
getAllSnippets,
|
||||
createSnippet as createSnippetDB,
|
||||
updateSnippet as updateSnippetDB,
|
||||
deleteSnippet as deleteSnippetDB,
|
||||
getSnippetsByNamespace,
|
||||
bulkMoveSnippets as bulkMoveSnippetsDB,
|
||||
} from '@/lib/db'
|
||||
|
||||
interface SnippetsState {
|
||||
items: Snippet[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
selectedIds: Set<string>
|
||||
selectionMode: boolean
|
||||
}
|
||||
|
||||
const initialState: SnippetsState = {
|
||||
items: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
selectedIds: new Set(),
|
||||
selectionMode: false,
|
||||
}
|
||||
|
||||
export const fetchAllSnippets = createAsyncThunk(
|
||||
'snippets/fetchAll',
|
||||
async () => {
|
||||
return await getAllSnippets()
|
||||
}
|
||||
)
|
||||
|
||||
export const fetchSnippetsByNamespace = createAsyncThunk(
|
||||
'snippets/fetchByNamespace',
|
||||
async (namespaceId: string) => {
|
||||
return await getSnippetsByNamespace(namespaceId)
|
||||
}
|
||||
)
|
||||
|
||||
export const createSnippet = createAsyncThunk(
|
||||
'snippets/create',
|
||||
async (snippetData: Omit<Snippet, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
const newSnippet: Snippet = {
|
||||
...snippetData,
|
||||
id: Date.now().toString(),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
await createSnippetDB(newSnippet)
|
||||
return newSnippet
|
||||
}
|
||||
)
|
||||
|
||||
export const updateSnippet = createAsyncThunk(
|
||||
'snippets/update',
|
||||
async (snippet: Snippet) => {
|
||||
const updatedSnippet = {
|
||||
...snippet,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
await updateSnippetDB(updatedSnippet)
|
||||
return updatedSnippet
|
||||
}
|
||||
)
|
||||
|
||||
export const deleteSnippet = createAsyncThunk(
|
||||
'snippets/delete',
|
||||
async (id: string) => {
|
||||
await deleteSnippetDB(id)
|
||||
return id
|
||||
}
|
||||
)
|
||||
|
||||
export const bulkMoveSnippets = createAsyncThunk(
|
||||
'snippets/bulkMove',
|
||||
async ({ snippetIds, targetNamespaceId }: { snippetIds: string[], targetNamespaceId: string }) => {
|
||||
await bulkMoveSnippetsDB(snippetIds, targetNamespaceId)
|
||||
return { snippetIds, targetNamespaceId }
|
||||
}
|
||||
)
|
||||
|
||||
const snippetsSlice = createSlice({
|
||||
name: 'snippets',
|
||||
initialState,
|
||||
reducers: {
|
||||
toggleSelectionMode: (state) => {
|
||||
state.selectionMode = !state.selectionMode
|
||||
if (!state.selectionMode) {
|
||||
state.selectedIds = new Set()
|
||||
}
|
||||
},
|
||||
toggleSnippetSelection: (state, action: PayloadAction<string>) => {
|
||||
const newSet = new Set(state.selectedIds)
|
||||
if (newSet.has(action.payload)) {
|
||||
newSet.delete(action.payload)
|
||||
} else {
|
||||
newSet.add(action.payload)
|
||||
}
|
||||
state.selectedIds = newSet
|
||||
},
|
||||
clearSelection: (state) => {
|
||||
state.selectedIds = new Set()
|
||||
},
|
||||
selectAllSnippets: (state) => {
|
||||
state.selectedIds = new Set(state.items.map(s => s.id))
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchAllSnippets.pending, (state) => {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
})
|
||||
.addCase(fetchAllSnippets.fulfilled, (state, action) => {
|
||||
state.loading = false
|
||||
state.items = action.payload
|
||||
})
|
||||
.addCase(fetchAllSnippets.rejected, (state, action) => {
|
||||
state.loading = false
|
||||
state.error = action.error.message || 'Failed to fetch snippets'
|
||||
})
|
||||
.addCase(fetchSnippetsByNamespace.pending, (state) => {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
})
|
||||
.addCase(fetchSnippetsByNamespace.fulfilled, (state, action) => {
|
||||
state.loading = false
|
||||
state.items = action.payload
|
||||
})
|
||||
.addCase(fetchSnippetsByNamespace.rejected, (state, action) => {
|
||||
state.loading = false
|
||||
state.error = action.error.message || 'Failed to fetch snippets'
|
||||
})
|
||||
.addCase(createSnippet.fulfilled, (state, action) => {
|
||||
state.items.unshift(action.payload)
|
||||
})
|
||||
.addCase(updateSnippet.fulfilled, (state, action) => {
|
||||
const index = state.items.findIndex(s => s.id === action.payload.id)
|
||||
if (index !== -1) {
|
||||
state.items[index] = action.payload
|
||||
}
|
||||
})
|
||||
.addCase(deleteSnippet.fulfilled, (state, action) => {
|
||||
state.items = state.items.filter(s => s.id !== action.payload)
|
||||
})
|
||||
.addCase(bulkMoveSnippets.fulfilled, (state, action) => {
|
||||
const { snippetIds, targetNamespaceId } = action.payload
|
||||
state.items.forEach(snippet => {
|
||||
if (snippetIds.includes(snippet.id)) {
|
||||
snippet.namespaceId = targetNamespaceId
|
||||
}
|
||||
})
|
||||
state.selectedIds = new Set()
|
||||
state.selectionMode = false
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const {
|
||||
toggleSelectionMode,
|
||||
toggleSnippetSelection,
|
||||
clearSelection,
|
||||
selectAllSnippets,
|
||||
} = snippetsSlice.actions
|
||||
|
||||
export default snippetsSlice.reducer
|
||||
54
src/store/slices/uiSlice.ts
Normal file
54
src/store/slices/uiSlice.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import { Snippet } from '@/lib/types'
|
||||
|
||||
interface UiState {
|
||||
dialogOpen: boolean
|
||||
viewerOpen: boolean
|
||||
editingSnippet: Snippet | null
|
||||
viewingSnippet: Snippet | null
|
||||
searchQuery: string
|
||||
}
|
||||
|
||||
const initialState: UiState = {
|
||||
dialogOpen: false,
|
||||
viewerOpen: false,
|
||||
editingSnippet: null,
|
||||
viewingSnippet: null,
|
||||
searchQuery: '',
|
||||
}
|
||||
|
||||
const uiSlice = createSlice({
|
||||
name: 'ui',
|
||||
initialState,
|
||||
reducers: {
|
||||
openDialog: (state, action: PayloadAction<Snippet | null>) => {
|
||||
state.dialogOpen = true
|
||||
state.editingSnippet = action.payload
|
||||
},
|
||||
closeDialog: (state) => {
|
||||
state.dialogOpen = false
|
||||
state.editingSnippet = null
|
||||
},
|
||||
openViewer: (state, action: PayloadAction<Snippet>) => {
|
||||
state.viewerOpen = true
|
||||
state.viewingSnippet = action.payload
|
||||
},
|
||||
closeViewer: (state) => {
|
||||
state.viewerOpen = false
|
||||
state.viewingSnippet = null
|
||||
},
|
||||
setSearchQuery: (state, action: PayloadAction<string>) => {
|
||||
state.searchQuery = action.payload
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const {
|
||||
openDialog,
|
||||
closeDialog,
|
||||
openViewer,
|
||||
closeViewer,
|
||||
setSearchQuery,
|
||||
} = uiSlice.actions
|
||||
|
||||
export default uiSlice.reducer
|
||||
Reference in New Issue
Block a user