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'
|
'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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue