feat: [US-033] - Auto-save to LocalStorage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gustavo Henrique Santos Souza de Miranda 2026-01-22 18:14:49 -03:00
parent c3975dd91a
commit f6ab24c5b3
1 changed files with 160 additions and 2 deletions

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import ReactFlow, { import ReactFlow, {
Background, Background,
BackgroundVariant, BackgroundVariant,
@ -30,6 +30,12 @@ import ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu'
import ConditionEditor from '@/components/editor/ConditionEditor' import ConditionEditor from '@/components/editor/ConditionEditor'
import type { FlowchartData, FlowchartNode, FlowchartEdge, Condition } from '@/types/flowchart' 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 = { type ContextMenuState = {
x: number x: number
y: 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 // Inner component that uses useReactFlow hook
function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { function FlowchartEditorInner({ projectId, initialData }: FlowchartEditorProps) {
// Define custom node types - memoized to prevent re-renders // Define custom node types - memoized to prevent re-renders
const nodeTypes: NodeTypes = useMemo( const nodeTypes: NodeTypes = useMemo(
() => ({ () => ({
@ -99,6 +167,22 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null) const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
const [conditionEditor, setConditionEditor] = useState<ConditionEditorState>(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( const [nodes, setNodes, onNodesChange] = useNodesState(
toReactFlowNodes(initialData.nodes) toReactFlowNodes(initialData.nodes)
) )
@ -106,6 +190,51 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
toReactFlowEdges(initialData.edges) 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( const onConnect = useCallback(
(params: Connection) => { (params: Connection) => {
if (!params.source || !params.target) return if (!params.source || !params.target) return
@ -428,6 +557,35 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
onCancel={closeConditionEditor} 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> </div>
) )
} }