mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 13:44:54 +00:00
Merge pull request #51 from johndoe6345789/codex/refactor-persistence-components-and-extract-ui-labels
Refactor persistence/storage components and externalize UI copy to JSON
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
80
src/data/persistence-dashboard.json
Normal file
80
src/data/persistence-dashboard.json
Normal 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}}"
|
||||
}
|
||||
}
|
||||
57
src/data/persistence-example.json
Normal file
57
src/data/persistence-example.json
Normal 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"
|
||||
}
|
||||
}
|
||||
36
src/data/storage-example.json
Normal file
36
src/data/storage-example.json
Normal 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])"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user