Generated by Spark: Implement redux

This commit is contained in:
2026-01-17 20:58:00 +00:00
committed by GitHub
parent 948a520450
commit 7123b5429b
14 changed files with 1343 additions and 40 deletions

104
PRD.md
View File

@@ -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
View 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
View File

@@ -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",

View File

@@ -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",

View 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>
)
}

View File

@@ -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>
)

View File

@@ -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
View 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
View 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
View 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
View 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))
)

View 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

View 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

View 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