feat: [US-033] - Auto-save to LocalStorage
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c3975dd91a
commit
f6ab24c5b3
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
|
|
@ -30,6 +30,12 @@ import ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu'
|
|||
import ConditionEditor from '@/components/editor/ConditionEditor'
|
||||
import type { FlowchartData, FlowchartNode, FlowchartEdge, Condition } from '@/types/flowchart'
|
||||
|
||||
// LocalStorage key prefix for draft saves
|
||||
const DRAFT_KEY_PREFIX = 'vnwrite-draft-'
|
||||
|
||||
// Debounce delay in ms
|
||||
const AUTOSAVE_DEBOUNCE_MS = 1000
|
||||
|
||||
type ContextMenuState = {
|
||||
x: number
|
||||
y: number
|
||||
|
|
@ -74,8 +80,70 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] {
|
|||
}))
|
||||
}
|
||||
|
||||
// Convert React Flow Node type back to our FlowchartNode type
|
||||
function fromReactFlowNodes(nodes: Node[]): FlowchartNode[] {
|
||||
return nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: node.type as 'dialogue' | 'choice' | 'variable',
|
||||
position: node.position,
|
||||
data: node.data,
|
||||
})) as FlowchartNode[]
|
||||
}
|
||||
|
||||
// Convert React Flow Edge type back to our FlowchartEdge type
|
||||
function fromReactFlowEdges(edges: Edge[]): FlowchartEdge[] {
|
||||
return edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
sourceHandle: edge.sourceHandle ?? undefined,
|
||||
target: edge.target,
|
||||
targetHandle: edge.targetHandle ?? undefined,
|
||||
data: edge.data,
|
||||
}))
|
||||
}
|
||||
|
||||
// Get LocalStorage key for a project
|
||||
function getDraftKey(projectId: string): string {
|
||||
return `${DRAFT_KEY_PREFIX}${projectId}`
|
||||
}
|
||||
|
||||
// Save draft to LocalStorage
|
||||
function saveDraft(projectId: string, data: FlowchartData): void {
|
||||
try {
|
||||
localStorage.setItem(getDraftKey(projectId), JSON.stringify(data))
|
||||
} catch (error) {
|
||||
console.error('Failed to save draft to LocalStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Load draft from LocalStorage
|
||||
function loadDraft(projectId: string): FlowchartData | null {
|
||||
try {
|
||||
const draft = localStorage.getItem(getDraftKey(projectId))
|
||||
if (!draft) return null
|
||||
return JSON.parse(draft) as FlowchartData
|
||||
} catch (error) {
|
||||
console.error('Failed to load draft from LocalStorage:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Clear draft from LocalStorage
|
||||
export function clearDraft(projectId: string): void {
|
||||
try {
|
||||
localStorage.removeItem(getDraftKey(projectId))
|
||||
} catch (error) {
|
||||
console.error('Failed to clear draft from LocalStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Compare two FlowchartData objects for equality
|
||||
function flowchartDataEquals(a: FlowchartData, b: FlowchartData): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
}
|
||||
|
||||
// Inner component that uses useReactFlow hook
|
||||
function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
||||
function FlowchartEditorInner({ projectId, initialData }: FlowchartEditorProps) {
|
||||
// Define custom node types - memoized to prevent re-renders
|
||||
const nodeTypes: NodeTypes = useMemo(
|
||||
() => ({
|
||||
|
|
@ -99,6 +167,22 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
|||
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
|
||||
const [conditionEditor, setConditionEditor] = useState<ConditionEditorState>(null)
|
||||
|
||||
// Check for saved draft on initial render (lazy initialization)
|
||||
const [draftState, setDraftState] = useState<{
|
||||
showPrompt: boolean
|
||||
savedDraft: FlowchartData | null
|
||||
}>(() => {
|
||||
// This runs only once on initial render (client-side)
|
||||
if (typeof window === 'undefined') {
|
||||
return { showPrompt: false, savedDraft: null }
|
||||
}
|
||||
const draft = loadDraft(projectId)
|
||||
if (draft && !flowchartDataEquals(draft, initialData)) {
|
||||
return { showPrompt: true, savedDraft: draft }
|
||||
}
|
||||
return { showPrompt: false, savedDraft: null }
|
||||
})
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(
|
||||
toReactFlowNodes(initialData.nodes)
|
||||
)
|
||||
|
|
@ -106,6 +190,51 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
|||
toReactFlowEdges(initialData.edges)
|
||||
)
|
||||
|
||||
// Track debounce timer
|
||||
const saveTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Debounced auto-save to LocalStorage
|
||||
useEffect(() => {
|
||||
// Don't save while draft prompt is showing
|
||||
if (draftState.showPrompt) return
|
||||
|
||||
// Clear existing timer
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
}
|
||||
|
||||
// Set new timer
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
const currentData: FlowchartData = {
|
||||
nodes: fromReactFlowNodes(nodes),
|
||||
edges: fromReactFlowEdges(edges),
|
||||
}
|
||||
saveDraft(projectId, currentData)
|
||||
}, AUTOSAVE_DEBOUNCE_MS)
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [nodes, edges, projectId, draftState.showPrompt])
|
||||
|
||||
// Handle restoring draft
|
||||
const handleRestoreDraft = useCallback(() => {
|
||||
if (draftState.savedDraft) {
|
||||
setNodes(toReactFlowNodes(draftState.savedDraft.nodes))
|
||||
setEdges(toReactFlowEdges(draftState.savedDraft.edges))
|
||||
}
|
||||
setDraftState({ showPrompt: false, savedDraft: null })
|
||||
}, [draftState.savedDraft, setNodes, setEdges])
|
||||
|
||||
// Handle discarding draft
|
||||
const handleDiscardDraft = useCallback(() => {
|
||||
clearDraft(projectId)
|
||||
setDraftState({ showPrompt: false, savedDraft: null })
|
||||
}, [projectId])
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) => {
|
||||
if (!params.source || !params.target) return
|
||||
|
|
@ -428,6 +557,35 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
|||
onCancel={closeConditionEditor}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Draft restoration prompt */}
|
||||
{draftState.showPrompt && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-900">
|
||||
<h2 className="mb-2 text-lg font-semibold text-zinc-900 dark:text-white">
|
||||
Unsaved Draft Found
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
A local draft was found that differs from the saved version. Would
|
||||
you like to restore it or discard it?
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleRestoreDraft}
|
||||
className="flex-1 rounded-md bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
Restore Draft
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDiscardDraft}
|
||||
className="flex-1 rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue