mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-26 14:44:55 +00:00
Generated by Spark: Create Redux persistence middleware to sync state with database automatically
This commit is contained in:
288
src/components/PersistenceDashboard.tsx
Normal file
288
src/components/PersistenceDashboard.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Database,
|
||||
CloudArrowUp,
|
||||
CloudArrowDown,
|
||||
ArrowsClockwise,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
ChartLine,
|
||||
Gauge
|
||||
} from '@phosphor-icons/react'
|
||||
import { usePersistence } from '@/hooks/use-persistence'
|
||||
import { useAppDispatch, useAppSelector } from '@/store'
|
||||
import { syncToFlaskBulk, syncFromFlaskBulk, checkFlaskConnection } from '@/store/slices/syncSlice'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function PersistenceDashboard() {
|
||||
const dispatch = useAppDispatch()
|
||||
const { status, metrics, autoSyncStatus, syncNow, configureAutoSync } = usePersistence()
|
||||
const [autoSyncEnabled, setAutoSyncEnabled] = useState(false)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(checkFlaskConnection())
|
||||
const interval = setInterval(() => {
|
||||
dispatch(checkFlaskConnection())
|
||||
}, 10000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [dispatch])
|
||||
|
||||
const handleSyncToFlask = async () => {
|
||||
setSyncing(true)
|
||||
try {
|
||||
await dispatch(syncToFlaskBulk()).unwrap()
|
||||
toast.success('Successfully synced to Flask')
|
||||
} catch (error: any) {
|
||||
toast.error(`Sync failed: ${error}`)
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSyncFromFlask = async () => {
|
||||
setSyncing(true)
|
||||
try {
|
||||
await dispatch(syncFromFlaskBulk()).unwrap()
|
||||
toast.success('Successfully synced from Flask')
|
||||
} catch (error: any) {
|
||||
toast.error(`Sync failed: ${error}`)
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoSyncToggle = (enabled: boolean) => {
|
||||
setAutoSyncEnabled(enabled)
|
||||
configureAutoSync({ enabled, syncOnChange: true })
|
||||
toast.info(enabled ? 'Auto-sync enabled' : 'Auto-sync disabled')
|
||||
}
|
||||
|
||||
const handleManualSync = async () => {
|
||||
try {
|
||||
await syncNow()
|
||||
toast.success('Manual sync completed')
|
||||
} catch (error: any) {
|
||||
toast.error(`Manual sync failed: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (!status.flaskConnected) return 'bg-destructive'
|
||||
if (status.syncStatus === 'syncing') return 'bg-amber-500'
|
||||
if (status.syncStatus === 'success') return 'bg-accent'
|
||||
if (status.syncStatus === 'error') return 'bg-destructive'
|
||||
return 'bg-muted'
|
||||
}
|
||||
|
||||
const getStatusText = () => {
|
||||
if (!status.flaskConnected) return 'Disconnected'
|
||||
if (status.syncStatus === 'syncing') return 'Syncing...'
|
||||
if (status.syncStatus === 'success') return 'Synced'
|
||||
if (status.syncStatus === 'error') return 'Error'
|
||||
return 'Idle'
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number | null) => {
|
||||
if (!timestamp) return 'Never'
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleTimeString()
|
||||
}
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
const getSuccessRate = () => {
|
||||
if (metrics.totalOperations === 0) return 0
|
||||
return Math.round((metrics.successfulOperations / metrics.totalOperations) * 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold">Persistence & Sync Dashboard</h1>
|
||||
<Badge className={`${getStatusColor()} text-white`}>
|
||||
{getStatusText()}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<Card className="p-6 space-y-4 border-sidebar-border hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Database className="text-primary" />
|
||||
Connection Status
|
||||
</h3>
|
||||
{status.flaskConnected ? (
|
||||
<CheckCircle className="text-accent" weight="fill" />
|
||||
) : (
|
||||
<XCircle className="text-destructive" weight="fill" />
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Local Storage:</span>
|
||||
<span className="font-medium">IndexedDB</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Remote Storage:</span>
|
||||
<span className="font-medium">
|
||||
{status.flaskConnected ? 'Flask API' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Last Sync:</span>
|
||||
<span className="font-medium">{formatTime(status.lastSyncTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 space-y-4 border-sidebar-border hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<ChartLine className="text-accent" />
|
||||
Sync Metrics
|
||||
</h3>
|
||||
<Gauge className="text-muted-foreground" />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Total Operations:</span>
|
||||
<span className="font-medium">{metrics.totalOperations}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Success Rate:</span>
|
||||
<span className="font-medium text-accent">{getSuccessRate()}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Avg Duration:</span>
|
||||
<span className="font-medium">{formatDuration(metrics.averageOperationTime)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Failed:</span>
|
||||
<span className="font-medium text-destructive">{metrics.failedOperations}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 space-y-4 border-sidebar-border hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Clock className="text-amber-500" />
|
||||
Auto-Sync
|
||||
</h3>
|
||||
<Switch checked={autoSyncEnabled} onCheckedChange={handleAutoSyncToggle} />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Status:</span>
|
||||
<span className="font-medium">
|
||||
{autoSyncStatus.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Changes Pending:</span>
|
||||
<span className="font-medium">{autoSyncStatus.changeCounter}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Next Sync:</span>
|
||||
<span className="font-medium">
|
||||
{autoSyncStatus.nextSyncIn !== null
|
||||
? formatDuration(autoSyncStatus.nextSyncIn)
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="p-6 space-y-4 border-sidebar-border">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<ArrowsClockwise className="text-primary" />
|
||||
Manual Sync Operations
|
||||
</h3>
|
||||
<Separator />
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
onClick={handleSyncToFlask}
|
||||
disabled={syncing || !status.flaskConnected}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<CloudArrowUp />
|
||||
Push to Flask
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSyncFromFlask}
|
||||
disabled={syncing || !status.flaskConnected}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<CloudArrowDown />
|
||||
Pull from Flask
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleManualSync}
|
||||
disabled={syncing || !autoSyncStatus.enabled}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowsClockwise />
|
||||
Trigger Auto-Sync
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => dispatch(checkFlaskConnection())}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<CheckCircle />
|
||||
Check Connection
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{status.error && (
|
||||
<Card className="p-6 border-destructive bg-destructive/10">
|
||||
<div className="flex items-start gap-3">
|
||||
<XCircle className="text-destructive mt-1" weight="fill" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-destructive mb-1">Sync Error</h3>
|
||||
<p className="text-sm text-muted-foreground">{status.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="p-6 space-y-4 border-sidebar-border">
|
||||
<h3 className="text-lg font-semibold">How Persistence Works</h3>
|
||||
<Separator />
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<strong className="text-foreground">Automatic Persistence:</strong> All Redux state changes are automatically persisted to IndexedDB with a 300ms debounce.
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-foreground">Flask Sync:</strong> When connected, data is synced bidirectionally with the Flask API backend.
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-foreground">Auto-Sync:</strong> Enable auto-sync to automatically push changes to Flask at regular intervals (default: 30s).
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-foreground">Conflict Resolution:</strong> When conflicts are detected during sync, you'll be notified to resolve them manually.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
251
src/components/PersistenceExample.tsx
Normal file
251
src/components/PersistenceExample.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useState } from 'react'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { FloppyDisk, Trash, PencilSimple, CheckCircle, Clock } from '@phosphor-icons/react'
|
||||
import { useAppDispatch, useAppSelector } from '@/store'
|
||||
import { saveFile, deleteFile } from '@/store/slices/filesSlice'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function PersistenceExample() {
|
||||
const dispatch = useAppDispatch()
|
||||
const files = useAppSelector((state) => state.files.files)
|
||||
const [fileName, setFileName] = useState('')
|
||||
const [fileContent, setFileContent] = useState('')
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!fileName.trim()) {
|
||||
toast.error('File name is required')
|
||||
return
|
||||
}
|
||||
|
||||
const fileItem = {
|
||||
id: editingId || `file-${Date.now()}`,
|
||||
name: fileName,
|
||||
content: fileContent,
|
||||
language: 'javascript',
|
||||
path: `/src/${fileName}`,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
try {
|
||||
await dispatch(saveFile(fileItem)).unwrap()
|
||||
toast.success(`File "${fileName}" saved automatically!`, {
|
||||
description: 'Synced to IndexedDB and Flask API',
|
||||
})
|
||||
setFileName('')
|
||||
setFileContent('')
|
||||
setEditingId(null)
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to save file', {
|
||||
description: error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (file: any) => {
|
||||
setEditingId(file.id)
|
||||
setFileName(file.name)
|
||||
setFileContent(file.content)
|
||||
}
|
||||
|
||||
const handleDelete = async (fileId: string, name: string) => {
|
||||
try {
|
||||
await dispatch(deleteFile(fileId)).unwrap()
|
||||
toast.success(`File "${name}" deleted`, {
|
||||
description: 'Automatically synced to storage',
|
||||
})
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to delete file', {
|
||||
description: error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setFileName('')
|
||||
setFileContent('')
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">Persistence Middleware Example</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Demonstrates automatic persistence of Redux state to IndexedDB and Flask API
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="p-6 space-y-4 border-sidebar-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{editingId ? 'Edit File' : 'Create File'}
|
||||
</h2>
|
||||
{editingId && (
|
||||
<Badge variant="outline" className="text-amber-500 border-amber-500">
|
||||
Editing
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fileName">File Name</Label>
|
||||
<Input
|
||||
id="fileName"
|
||||
value={fileName}
|
||||
onChange={(e) => setFileName(e.target.value)}
|
||||
placeholder="example.js"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fileContent">Content</Label>
|
||||
<textarea
|
||||
id="fileContent"
|
||||
value={fileContent}
|
||||
onChange={(e) => setFileContent(e.target.value)}
|
||||
placeholder="console.log('Hello, world!')"
|
||||
className="w-full h-32 px-3 py-2 border border-input rounded-md bg-background font-mono text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} className="flex items-center gap-2 flex-1">
|
||||
<FloppyDisk />
|
||||
{editingId ? 'Update File' : 'Save File'}
|
||||
</Button>
|
||||
{editingId && (
|
||||
<Button onClick={handleCancel} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted/50 rounded-lg space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle className="text-accent mt-1" weight="fill" />
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold text-foreground">Automatic Persistence</p>
|
||||
<p className="text-muted-foreground">
|
||||
Data is automatically saved to IndexedDB with 300ms debounce
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Clock className="text-primary mt-1" weight="fill" />
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold text-foreground">Flask Sync</p>
|
||||
<p className="text-muted-foreground">
|
||||
Changes are synced to Flask API backend automatically
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 space-y-4 border-sidebar-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Saved Files</h2>
|
||||
<Badge variant="secondary">{files.length} files</Badge>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3 max-h-[500px] overflow-y-auto">
|
||||
{files.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>No files yet</p>
|
||||
<p className="text-sm mt-1">Create your first file to see it appear here</p>
|
||||
</div>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<Card
|
||||
key={file.id}
|
||||
className="p-4 space-y-2 hover:bg-muted/50 transition-colors border-sidebar-border"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold truncate">{file.name}</h3>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{file.path}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="ml-2 shrink-0">
|
||||
{file.language}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{file.content && (
|
||||
<div className="bg-muted/50 p-2 rounded text-xs font-mono text-muted-foreground max-h-20 overflow-hidden">
|
||||
{file.content.substring(0, 100)}
|
||||
{file.content.length > 100 && '...'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
Updated: {new Date(file.updatedAt).toLocaleTimeString()}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleEdit(file)}
|
||||
className="h-8 flex items-center gap-1"
|
||||
>
|
||||
<PencilSimple />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDelete(file.id, file.name)}
|
||||
className="h-8 flex items-center gap-1 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="p-6 space-y-4 border-primary/50 bg-primary/5">
|
||||
<h3 className="text-lg font-semibold">How It Works</h3>
|
||||
<Separator />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold text-primary">1. Redux Action</div>
|
||||
<p className="text-muted-foreground">
|
||||
When you save or delete a file, a Redux action is dispatched
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold text-primary">2. Middleware Intercepts</div>
|
||||
<p className="text-muted-foreground">
|
||||
Persistence middleware automatically intercepts the action and queues the operation
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold text-primary">3. Auto-Sync</div>
|
||||
<p className="text-muted-foreground">
|
||||
After 300ms debounce, data is saved to IndexedDB and synced to Flask API
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user