Refactor persistence and storage examples

This commit is contained in:
2026-01-18 00:39:32 +00:00
parent 1d6c968386
commit b3a933ca94
6 changed files with 828 additions and 523 deletions

View File

@@ -4,22 +4,286 @@ 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,
import {
Database,
CloudArrowUp,
CloudArrowDown,
ArrowsClockwise,
CheckCircle,
XCircle,
Clock,
ChartLine,
Gauge
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 { useAppDispatch } from '@/store'
import {
syncToFlaskBulk,
syncFromFlaskBulk,
checkFlaskConnection,
} from '@/store/slices/syncSlice'
import { toast } from 'sonner'
import copy from '@/data/persistence-dashboard.json'
const formatDuration = (ms: number) => {
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
type PersistenceStatus = ReturnType<typeof usePersistence>['status']
type PersistenceMetrics = ReturnType<typeof usePersistence>['metrics']
type AutoSyncStatus = ReturnType<typeof usePersistence>['autoSyncStatus']
const getStatusColor = (status: PersistenceStatus) => {
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 = (status: PersistenceStatus) => {
if (!status.flaskConnected) return copy.status.disconnected
if (status.syncStatus === 'syncing') return copy.status.syncing
if (status.syncStatus === 'success') return copy.status.synced
if (status.syncStatus === 'error') return copy.status.error
return copy.status.idle
}
const formatTime = (timestamp: number | null) => {
if (!timestamp) return copy.format.never
const date = new Date(timestamp)
return date.toLocaleTimeString()
}
const getSuccessRate = (metrics: PersistenceMetrics) => {
if (metrics.totalOperations === 0) return 0
return Math.round((metrics.successfulOperations / metrics.totalOperations) * 100)
}
type HeaderProps = {
status: PersistenceStatus
}
const DashboardHeader = ({ status }: HeaderProps) => (
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">{copy.title}</h1>
<Badge className={`${getStatusColor(status)} text-white`}>
{getStatusText(status)}
</Badge>
</div>
)
type ConnectionStatusCardProps = {
status: PersistenceStatus
}
const ConnectionStatusCard = ({ status }: ConnectionStatusCardProps) => (
<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" />
{copy.cards.connection.title}
</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">{copy.cards.connection.localStorageLabel}</span>
<span className="font-medium">{copy.cards.connection.localStorageValue}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{copy.cards.connection.remoteStorageLabel}</span>
<span className="font-medium">
{status.flaskConnected
? copy.cards.connection.remoteStorageConnected
: copy.cards.connection.remoteStorageDisconnected}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{copy.cards.connection.lastSyncLabel}</span>
<span className="font-medium">{formatTime(status.lastSyncTime)}</span>
</div>
</div>
</Card>
)
type SyncMetricsCardProps = {
metrics: PersistenceMetrics
}
const SyncMetricsCard = ({ metrics }: SyncMetricsCardProps) => (
<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" />
{copy.cards.metrics.title}
</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">{copy.cards.metrics.totalOperationsLabel}</span>
<span className="font-medium">{metrics.totalOperations}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{copy.cards.metrics.successRateLabel}</span>
<span className="font-medium text-accent">{getSuccessRate(metrics)}%</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{copy.cards.metrics.avgDurationLabel}</span>
<span className="font-medium">{formatDuration(metrics.averageOperationTime)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{copy.cards.metrics.failedLabel}</span>
<span className="font-medium text-destructive">{metrics.failedOperations}</span>
</div>
</div>
</Card>
)
type AutoSyncCardProps = {
autoSyncStatus: AutoSyncStatus
autoSyncEnabled: boolean
onToggle: (enabled: boolean) => void
}
const AutoSyncCard = ({ autoSyncStatus, autoSyncEnabled, onToggle }: AutoSyncCardProps) => (
<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" />
{copy.cards.autoSync.title}
</h3>
<Switch checked={autoSyncEnabled} onCheckedChange={onToggle} />
</div>
<Separator />
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{copy.cards.autoSync.statusLabel}</span>
<span className="font-medium">
{autoSyncStatus.enabled
? copy.cards.autoSync.statusEnabled
: copy.cards.autoSync.statusDisabled}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{copy.cards.autoSync.changesPendingLabel}</span>
<span className="font-medium">{autoSyncStatus.changeCounter}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{copy.cards.autoSync.nextSyncLabel}</span>
<span className="font-medium">
{autoSyncStatus.nextSyncIn !== null
? formatDuration(autoSyncStatus.nextSyncIn)
: copy.cards.autoSync.nextSyncNotAvailable}
</span>
</div>
</div>
</Card>
)
type ManualSyncCardProps = {
onSyncToFlask: () => void
onSyncFromFlask: () => void
onManualSync: () => void
onCheckConnection: () => void
syncing: boolean
status: PersistenceStatus
autoSyncStatus: AutoSyncStatus
}
const ManualSyncCard = ({
onSyncToFlask,
onSyncFromFlask,
onManualSync,
onCheckConnection,
syncing,
status,
autoSyncStatus,
}: ManualSyncCardProps) => (
<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" />
{copy.cards.manualSync.title}
</h3>
<Separator />
<div className="flex flex-wrap gap-3">
<Button
onClick={onSyncToFlask}
disabled={syncing || !status.flaskConnected}
className="flex items-center gap-2"
>
<CloudArrowUp />
{copy.cards.manualSync.pushButton}
</Button>
<Button
onClick={onSyncFromFlask}
disabled={syncing || !status.flaskConnected}
variant="outline"
className="flex items-center gap-2"
>
<CloudArrowDown />
{copy.cards.manualSync.pullButton}
</Button>
<Button
onClick={onManualSync}
disabled={syncing || !autoSyncStatus.enabled}
variant="outline"
className="flex items-center gap-2"
>
<ArrowsClockwise />
{copy.cards.manualSync.triggerButton}
</Button>
<Button
onClick={onCheckConnection}
variant="outline"
className="flex items-center gap-2"
>
<CheckCircle />
{copy.cards.manualSync.checkButton}
</Button>
</div>
</Card>
)
type SyncErrorCardProps = {
error: string
}
const SyncErrorCard = ({ error }: SyncErrorCardProps) => (
<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">{copy.cards.error.title}</h3>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
</Card>
)
const HowPersistenceWorksCard = () => (
<Card className="p-6 space-y-4 border-sidebar-border">
<h3 className="text-lg font-semibold">{copy.cards.howItWorks.title}</h3>
<Separator />
<div className="space-y-3 text-sm text-muted-foreground">
{copy.cards.howItWorks.items.map((item) => (
<p key={item.title}>
<strong className="text-foreground">{item.title}</strong> {item.description}
</p>
))}
</div>
</Card>
)
export function PersistenceDashboard() {
const dispatch = useAppDispatch()
@@ -40,9 +304,9 @@ export function PersistenceDashboard() {
setSyncing(true)
try {
await dispatch(syncToFlaskBulk()).unwrap()
toast.success('Successfully synced to Flask')
toast.success(copy.toasts.syncToSuccess)
} catch (error: any) {
toast.error(`Sync failed: ${error}`)
toast.error(copy.toasts.syncFailed.replace('{{error}}', String(error)))
} finally {
setSyncing(false)
}
@@ -52,9 +316,9 @@ export function PersistenceDashboard() {
setSyncing(true)
try {
await dispatch(syncFromFlaskBulk()).unwrap()
toast.success('Successfully synced from Flask')
toast.success(copy.toasts.syncFromSuccess)
} catch (error: any) {
toast.error(`Sync failed: ${error}`)
toast.error(copy.toasts.syncFailed.replace('{{error}}', String(error)))
} finally {
setSyncing(false)
}
@@ -63,226 +327,45 @@ export function PersistenceDashboard() {
const handleAutoSyncToggle = (enabled: boolean) => {
setAutoSyncEnabled(enabled)
configureAutoSync({ enabled, syncOnChange: true })
toast.info(enabled ? 'Auto-sync enabled' : 'Auto-sync disabled')
toast.info(enabled ? copy.toasts.autoSyncEnabled : copy.toasts.autoSyncDisabled)
}
const handleManualSync = async () => {
try {
await syncNow()
toast.success('Manual sync completed')
toast.success(copy.toasts.manualSyncSuccess)
} catch (error: any) {
toast.error(`Manual sync failed: ${error}`)
toast.error(copy.toasts.manualSyncFailed.replace('{{error}}', String(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>
<DashboardHeader status={status} />
<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>
<ConnectionStatusCard status={status} />
<SyncMetricsCard metrics={metrics} />
<AutoSyncCard
autoSyncStatus={autoSyncStatus}
autoSyncEnabled={autoSyncEnabled}
onToggle={handleAutoSyncToggle}
/>
</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>
<ManualSyncCard
onSyncToFlask={handleSyncToFlask}
onSyncFromFlask={handleSyncFromFlask}
onManualSync={handleManualSync}
onCheckConnection={() => dispatch(checkFlaskConnection())}
syncing={syncing}
status={status}
autoSyncStatus={autoSyncStatus}
/>
{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>
)}
{status.error && <SyncErrorCard error={status.error} />}
<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>
<HowPersistenceWorksCard />
</div>
)
}

View File

@@ -7,8 +7,199 @@ 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 { saveFile, deleteFile, type FileItem } from '@/store/slices/filesSlice'
import { toast } from 'sonner'
import copy from '@/data/persistence-example.json'
type HeaderProps = {
title: string
description: string
}
const PersistenceExampleHeader = ({ title, description }: HeaderProps) => (
<div>
<h1 className="text-3xl font-bold mb-2">{title}</h1>
<p className="text-muted-foreground">{description}</p>
</div>
)
type FileEditorCardProps = {
fileName: string
fileContent: string
editingId: string | null
onFileNameChange: (value: string) => void
onFileContentChange: (value: string) => void
onSave: () => void
onCancel: () => void
}
const FileEditorCard = ({
fileName,
fileContent,
editingId,
onFileNameChange,
onFileContentChange,
onSave,
onCancel,
}: FileEditorCardProps) => (
<Card className="p-6 space-y-4 border-sidebar-border">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">
{editingId ? copy.editor.titleEdit : copy.editor.titleCreate}
</h2>
{editingId && (
<Badge variant="outline" className="text-amber-500 border-amber-500">
{copy.editor.editingBadge}
</Badge>
)}
</div>
<Separator />
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="fileName">{copy.editor.fileNameLabel}</Label>
<Input
id="fileName"
value={fileName}
onChange={(e) => onFileNameChange(e.target.value)}
placeholder={copy.editor.fileNamePlaceholder}
/>
</div>
<div className="space-y-2">
<Label htmlFor="fileContent">{copy.editor.contentLabel}</Label>
<textarea
id="fileContent"
value={fileContent}
onChange={(e) => onFileContentChange(e.target.value)}
placeholder={copy.editor.contentPlaceholder}
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={onSave} className="flex items-center gap-2 flex-1">
<FloppyDisk />
{editingId ? copy.editor.updateButton : copy.editor.saveButton}
</Button>
{editingId && (
<Button onClick={onCancel} variant="outline">
{copy.editor.cancelButton}
</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">{copy.info.automaticTitle}</p>
<p className="text-muted-foreground">{copy.info.automaticDescription}</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">{copy.info.flaskTitle}</p>
<p className="text-muted-foreground">{copy.info.flaskDescription}</p>
</div>
</div>
</div>
</div>
</Card>
)
type SavedFilesCardProps = {
files: FileItem[]
onEdit: (file: FileItem) => void
onDelete: (fileId: string, name: string) => void
}
const SavedFilesCard = ({ files, onEdit, onDelete }: SavedFilesCardProps) => (
<Card className="p-6 space-y-4 border-sidebar-border">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{copy.files.title}</h2>
<Badge variant="secondary">
{files.length} {copy.files.countLabel}
</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>{copy.files.emptyTitle}</p>
<p className="text-sm mt-1">{copy.files.emptyDescription}</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>
{copy.files.updatedLabel} {new Date(file.updatedAt).toLocaleTimeString()}
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => onEdit(file)}
className="h-8 flex items-center gap-1"
>
<PencilSimple />
{copy.files.editButton}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => onDelete(file.id, file.name)}
className="h-8 flex items-center gap-1 text-destructive hover:text-destructive"
>
<Trash />
{copy.files.deleteButton}
</Button>
</div>
</div>
</Card>
))
)}
</div>
</Card>
)
const HowItWorksCard = () => (
<Card className="p-6 space-y-4 border-primary/50 bg-primary/5">
<h3 className="text-lg font-semibold">{copy.howItWorks.title}</h3>
<Separator />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
{copy.howItWorks.steps.map((step) => (
<div className="space-y-2" key={step.title}>
<div className="font-semibold text-primary">{step.title}</div>
<p className="text-muted-foreground">{step.description}</p>
</div>
))}
</div>
</Card>
)
export function PersistenceExample() {
const dispatch = useAppDispatch()
@@ -19,11 +210,11 @@ export function PersistenceExample() {
const handleSave = async () => {
if (!fileName.trim()) {
toast.error('File name is required')
toast.error(copy.toasts.fileNameRequired)
return
}
const fileItem = {
const fileItem: FileItem = {
id: editingId || `file-${Date.now()}`,
name: fileName,
content: fileContent,
@@ -34,20 +225,20 @@ export function PersistenceExample() {
try {
await dispatch(saveFile(fileItem)).unwrap()
toast.success(`File "${fileName}" saved automatically!`, {
description: 'Synced to IndexedDB and Flask API',
toast.success(copy.toasts.saveSuccess.replace('{{name}}', fileName), {
description: copy.toasts.saveDescription,
})
setFileName('')
setFileContent('')
setEditingId(null)
} catch (error: any) {
toast.error('Failed to save file', {
toast.error(copy.toasts.saveErrorTitle, {
description: error,
})
}
}
const handleEdit = (file: any) => {
const handleEdit = (file: FileItem) => {
setEditingId(file.id)
setFileName(file.name)
setFileContent(file.content)
@@ -56,11 +247,11 @@ export function PersistenceExample() {
const handleDelete = async (fileId: string, name: string) => {
try {
await dispatch(deleteFile(fileId)).unwrap()
toast.success(`File "${name}" deleted`, {
description: 'Automatically synced to storage',
toast.success(copy.toasts.deleteSuccess.replace('{{name}}', name), {
description: copy.toasts.deleteDescription,
})
} catch (error: any) {
toast.error('Failed to delete file', {
toast.error(copy.toasts.deleteErrorTitle, {
description: error,
})
}
@@ -74,178 +265,23 @@ export function PersistenceExample() {
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>
<PersistenceExampleHeader title={copy.title} description={copy.description} />
<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 />
<FileEditorCard
fileName={fileName}
fileContent={fileContent}
editingId={editingId}
onFileNameChange={setFileName}
onFileContentChange={setFileContent}
onSave={handleSave}
onCancel={handleCancel}
/>
<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>
<SavedFilesCard files={files} onEdit={handleEdit} onDelete={handleDelete} />
</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>
<HowItWorksCard />
</div>
)
}

View File

@@ -1,11 +1,11 @@
import { useStorage } from '@/hooks/use-storage'
import { useIndexedDB, useIndexedDBCollection } from '@/hooks/use-indexed-db'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { useState } from 'react'
import { Database } from '@phosphor-icons/react'
import copy from '@/data/storage-example.json'
interface Todo {
id: string
@@ -14,9 +14,137 @@ interface Todo {
createdAt: number
}
type HeaderProps = {
title: string
description: string
}
const StorageExampleHeader = ({ title, description }: HeaderProps) => (
<div>
<h1 className="text-3xl font-bold mb-2 flex items-center gap-2">
<Database size={32} />
{title}
</h1>
<p className="text-muted-foreground">{description}</p>
</div>
)
type CounterCardProps = {
counter: number
onIncrement: () => void
}
const CounterCard = ({ counter, onIncrement }: CounterCardProps) => (
<Card>
<CardHeader>
<CardTitle>{copy.counter.title}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-center gap-4">
<Badge variant="outline" className="text-4xl py-4 px-8">
{counter}
</Badge>
</div>
<Button onClick={onIncrement} className="w-full" size="lg">
{copy.counter.incrementButton}
</Button>
<p className="text-xs text-muted-foreground text-center">{copy.counter.helper}</p>
</CardContent>
</Card>
)
type TodoListCardProps = {
todos: Todo[]
newTodoText: string
onTodoTextChange: (value: string) => void
onAddTodo: () => void
onToggleTodo: (id: string) => void
onDeleteTodo: (id: string) => void
}
const TodoListCard = ({
todos,
newTodoText,
onTodoTextChange,
onAddTodo,
onToggleTodo,
onDeleteTodo,
}: TodoListCardProps) => (
<Card>
<CardHeader>
<CardTitle>{copy.todo.title}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
value={newTodoText}
onChange={(e) => onTodoTextChange(e.target.value)}
placeholder={copy.todo.placeholder}
onKeyDown={(e) => e.key === 'Enter' && onAddTodo()}
/>
<Button onClick={onAddTodo}>{copy.todo.addButton}</Button>
</div>
<div className="space-y-2 max-h-64 overflow-y-auto">
{todos.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
{copy.todo.emptyState}
</p>
) : (
todos.map((todo) => (
<div key={todo.id} className="flex items-center gap-2 p-2 rounded border">
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggleTodo(todo.id)}
className="w-4 h-4"
/>
<span
className={`flex-1 ${
todo.completed ? 'line-through text-muted-foreground' : ''
}`}
>
{todo.text}
</span>
<Button variant="ghost" size="sm" onClick={() => onDeleteTodo(todo.id)}>
{copy.todo.deleteButton}
</Button>
</div>
))
)}
</div>
<p className="text-xs text-muted-foreground">{copy.todo.footer}</p>
</CardContent>
</Card>
)
const HowItWorksCard = () => (
<Card>
<CardHeader>
<CardTitle>{copy.howItWorks.title}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{copy.howItWorks.steps.map((step) => (
<div className="space-y-2" key={step.title}>
<h3 className="font-semibold">{step.title}</h3>
<p className="text-sm text-muted-foreground">{step.description}</p>
</div>
))}
</div>
<div className="bg-muted p-4 rounded-lg">
<h4 className="font-semibold mb-2">{copy.howItWorks.codeExampleTitle}</h4>
<pre className="text-xs overflow-x-auto">{copy.howItWorks.codeSample}</pre>
</div>
</CardContent>
</Card>
)
export function StorageExample() {
const [newTodoText, setNewTodoText] = useState('')
const [todos, setTodos] = useStorage<Todo[]>('example-todos', [])
const [counter, setCounter] = useStorage<number>('example-counter', 0)
@@ -37,9 +165,7 @@ export function StorageExample() {
const toggleTodo = (id: string) => {
setTodos((current) =>
current.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
current.map((todo) => (todo.id === id ? { ...todo, completed: !todo.completed } : todo))
)
}
@@ -53,134 +179,21 @@ export function StorageExample() {
return (
<div className="space-y-6 p-6">
<div>
<h1 className="text-3xl font-bold mb-2 flex items-center gap-2">
<Database size={32} />
Storage Example
</h1>
<p className="text-muted-foreground">
Demonstrates IndexedDB + Spark KV hybrid storage
</p>
</div>
<StorageExampleHeader title={copy.title} description={copy.description} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Simple Counter (useStorage)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-center gap-4">
<Badge variant="outline" className="text-4xl py-4 px-8">
{counter}
</Badge>
</div>
<Button onClick={incrementCounter} className="w-full" size="lg">
Increment
</Button>
<p className="text-xs text-muted-foreground text-center">
This counter persists across page refreshes using hybrid storage
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Todo List (useStorage)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)}
placeholder="Enter todo..."
onKeyDown={(e) => e.key === 'Enter' && addTodo()}
/>
<Button onClick={addTodo}>Add</Button>
</div>
<div className="space-y-2 max-h-64 overflow-y-auto">
{todos.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No todos yet. Add one above!
</p>
) : (
todos.map((todo) => (
<div
key={todo.id}
className="flex items-center gap-2 p-2 rounded border"
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
className="w-4 h-4"
/>
<span
className={`flex-1 ${
todo.completed ? 'line-through text-muted-foreground' : ''
}`}
>
{todo.text}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => deleteTodo(todo.id)}
>
Delete
</Button>
</div>
))
)}
</div>
<p className="text-xs text-muted-foreground">
Todos are stored in IndexedDB with Spark KV fallback
</p>
</CardContent>
</Card>
<CounterCard counter={counter} onIncrement={incrementCounter} />
<TodoListCard
todos={todos}
newTodoText={newTodoText}
onTodoTextChange={setNewTodoText}
onAddTodo={addTodo}
onToggleTodo={toggleTodo}
onDeleteTodo={deleteTodo}
/>
</div>
<Card>
<CardHeader>
<CardTitle>How It Works</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<h3 className="font-semibold">1. Primary: IndexedDB</h3>
<p className="text-sm text-muted-foreground">
Data is first saved to IndexedDB for fast, structured storage with indexes
</p>
</div>
<div className="space-y-2">
<h3 className="font-semibold">2. Fallback: Spark KV</h3>
<p className="text-sm text-muted-foreground">
If IndexedDB fails or is unavailable, Spark KV is used automatically
</p>
</div>
<div className="space-y-2">
<h3 className="font-semibold">3. Sync Both</h3>
<p className="text-sm text-muted-foreground">
Data is kept in sync between both storage systems for redundancy
</p>
</div>
</div>
<div className="bg-muted p-4 rounded-lg">
<h4 className="font-semibold mb-2">Code Example:</h4>
<pre className="text-xs overflow-x-auto">
{`import { useStorage } from '@/hooks/use-storage'
// Replaces useKV from Spark
const [todos, setTodos] = useStorage('todos', [])
// Use functional updates for safety
setTodos((current) => [...current, newTodo])`}
</pre>
</div>
</CardContent>
</Card>
<HowItWorksCard />
</div>
)
}

View File

@@ -0,0 +1,80 @@
{
"title": "Persistence & Sync Dashboard",
"status": {
"disconnected": "Disconnected",
"syncing": "Syncing...",
"synced": "Synced",
"error": "Error",
"idle": "Idle"
},
"format": {
"never": "Never"
},
"cards": {
"connection": {
"title": "Connection Status",
"localStorageLabel": "Local Storage:",
"localStorageValue": "IndexedDB",
"remoteStorageLabel": "Remote Storage:",
"remoteStorageConnected": "Flask API",
"remoteStorageDisconnected": "Offline",
"lastSyncLabel": "Last Sync:"
},
"metrics": {
"title": "Sync Metrics",
"totalOperationsLabel": "Total Operations:",
"successRateLabel": "Success Rate:",
"avgDurationLabel": "Avg Duration:",
"failedLabel": "Failed:"
},
"autoSync": {
"title": "Auto-Sync",
"statusLabel": "Status:",
"statusEnabled": "Enabled",
"statusDisabled": "Disabled",
"changesPendingLabel": "Changes Pending:",
"nextSyncLabel": "Next Sync:",
"nextSyncNotAvailable": "N/A"
},
"manualSync": {
"title": "Manual Sync Operations",
"pushButton": "Push to Flask",
"pullButton": "Pull from Flask",
"triggerButton": "Trigger Auto-Sync",
"checkButton": "Check Connection"
},
"error": {
"title": "Sync Error"
},
"howItWorks": {
"title": "How Persistence Works",
"items": [
{
"title": "Automatic Persistence:",
"description": "All Redux state changes are automatically persisted to IndexedDB with a 300ms debounce."
},
{
"title": "Flask Sync:",
"description": "When connected, data is synced bidirectionally with the Flask API backend."
},
{
"title": "Auto-Sync:",
"description": "Enable auto-sync to automatically push changes to Flask at regular intervals (default: 30s)."
},
{
"title": "Conflict Resolution:",
"description": "When conflicts are detected during sync, you'll be notified to resolve them manually."
}
]
}
},
"toasts": {
"syncToSuccess": "Successfully synced to Flask",
"syncFromSuccess": "Successfully synced from Flask",
"syncFailed": "Sync failed: {{error}}",
"autoSyncEnabled": "Auto-sync enabled",
"autoSyncDisabled": "Auto-sync disabled",
"manualSyncSuccess": "Manual sync completed",
"manualSyncFailed": "Manual sync failed: {{error}}"
}
}

View File

@@ -0,0 +1,57 @@
{
"title": "Persistence Middleware Example",
"description": "Demonstrates automatic persistence of Redux state to IndexedDB and Flask API",
"editor": {
"titleCreate": "Create File",
"titleEdit": "Edit File",
"editingBadge": "Editing",
"fileNameLabel": "File Name",
"fileNamePlaceholder": "example.js",
"contentLabel": "Content",
"contentPlaceholder": "console.log('Hello, world!')",
"saveButton": "Save File",
"updateButton": "Update File",
"cancelButton": "Cancel"
},
"info": {
"automaticTitle": "Automatic Persistence",
"automaticDescription": "Data is automatically saved to IndexedDB with 300ms debounce",
"flaskTitle": "Flask Sync",
"flaskDescription": "Changes are synced to Flask API backend automatically"
},
"files": {
"title": "Saved Files",
"countLabel": "files",
"emptyTitle": "No files yet",
"emptyDescription": "Create your first file to see it appear here",
"updatedLabel": "Updated:",
"editButton": "Edit",
"deleteButton": "Delete"
},
"howItWorks": {
"title": "How It Works",
"steps": [
{
"title": "1. Redux Action",
"description": "When you save or delete a file, a Redux action is dispatched"
},
{
"title": "2. Middleware Intercepts",
"description": "Persistence middleware automatically intercepts the action and queues the operation"
},
{
"title": "3. Auto-Sync",
"description": "After 300ms debounce, data is saved to IndexedDB and synced to Flask API"
}
]
},
"toasts": {
"fileNameRequired": "File name is required",
"saveSuccess": "File \"{{name}}\" saved automatically!",
"saveDescription": "Synced to IndexedDB and Flask API",
"saveErrorTitle": "Failed to save file",
"deleteSuccess": "File \"{{name}}\" deleted",
"deleteDescription": "Automatically synced to storage",
"deleteErrorTitle": "Failed to delete file"
}
}

View File

@@ -0,0 +1,36 @@
{
"title": "Storage Example",
"description": "Demonstrates IndexedDB + Spark KV hybrid storage",
"counter": {
"title": "Simple Counter (useStorage)",
"incrementButton": "Increment",
"helper": "This counter persists across page refreshes using hybrid storage"
},
"todo": {
"title": "Todo List (useStorage)",
"placeholder": "Enter todo...",
"addButton": "Add",
"emptyState": "No todos yet. Add one above!",
"deleteButton": "Delete",
"footer": "Todos are stored in IndexedDB with Spark KV fallback"
},
"howItWorks": {
"title": "How It Works",
"steps": [
{
"title": "1. Primary: IndexedDB",
"description": "Data is first saved to IndexedDB for fast, structured storage with indexes"
},
{
"title": "2. Fallback: Spark KV",
"description": "If IndexedDB fails or is unavailable, Spark KV is used automatically"
},
{
"title": "3. Sync Both",
"description": "Data is kept in sync between both storage systems for redundancy"
}
],
"codeExampleTitle": "Code Example:",
"codeSample": "import { useStorage } from '@/hooks/use-storage'\n\n// Replaces useKV from Spark\nconst [todos, setTodos] = useStorage('todos', [])\n\n// Use functional updates for safety\nsetTodos((current) => [...current, newTodo])"
}
}