diff --git a/cv-engine/src/App.tsx b/cv-engine/src/App.tsx index f485b54..fed8c1d 100644 --- a/cv-engine/src/App.tsx +++ b/cv-engine/src/App.tsx @@ -7,9 +7,12 @@ import SkillsEditor from './editors/SkillsEditor'; import SummaryEditor from './editors/SummaryEditor'; import PreviewPanel from './components/PreviewPanel'; import { useActiveStep } from './store/cvStore'; +import { useLocalAutosave } from './hooks/useLocalAutosave'; +import AutosaveStatus from './components/AutosaveStatus'; const App: React.FC = () => { const activeStep = useActiveStep(); + useLocalAutosave('cv-engine:draft', 800); // Render the appropriate editor based on the active step const renderEditor = () => { @@ -35,7 +38,10 @@ const App: React.FC = () => {
-

CV Engine

+
+

CV Engine

+ +
diff --git a/cv-engine/src/components/AutosaveStatus.tsx b/cv-engine/src/components/AutosaveStatus.tsx new file mode 100644 index 0000000..13d1195 --- /dev/null +++ b/cv-engine/src/components/AutosaveStatus.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { useSaveStatus, useLastSaved, useSaveError } from '../store/cvStore'; + +interface AutosaveStatusProps { + className?: string; +} + +const AutosaveStatus: React.FC = ({ className = '' }) => { + const status = useSaveStatus(); + const lastSaved = useLastSaved(); + const error = useSaveError(); + + let text = ''; + if (status === 'saving') text = 'Saving…'; + if (status === 'saved') text = `Saved ${lastSaved ? new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(lastSaved) : ''}`; + if (status === 'error') text = `Save error${error ? `: ${error}` : ''}`; + if (status === 'idle') text = 'All changes up to date'; + + const color = status === 'error' ? 'text-red-600' : status === 'saving' ? 'text-gray-600' : 'text-green-600'; + + return ( +
+ {text} +
+ ); +}; + +export default AutosaveStatus; \ No newline at end of file diff --git a/cv-engine/src/hooks/useLocalAutosave.ts b/cv-engine/src/hooks/useLocalAutosave.ts new file mode 100644 index 0000000..72a224d --- /dev/null +++ b/cv-engine/src/hooks/useLocalAutosave.ts @@ -0,0 +1,66 @@ +import { useEffect, useRef } from 'react'; +import { useCvStore } from '../store/cvStore'; +import { validateCV } from '../schema/cvSchema'; + +export function useLocalAutosave(storageKey = 'cv-engine:draft', debounceMs = 800) { + const cv = useCvStore(s => s.cv); + const isDirty = useCvStore(s => s.isDirty); + const hydrate = useCvStore(s => s.hydrate); + const setSaveStatus = useCvStore(s => s.setSaveStatus); + const setSaveError = useCvStore(s => s.setSaveError); + const markSaved = useCvStore(s => s.markSaved); + + const timerRef = useRef(null); + + // Load draft on mount + useEffect(() => { + try { + const raw = localStorage.getItem(storageKey); + const rawLastSaved = localStorage.getItem(`${storageKey}:lastSaved`); + if (raw) { + const parsed = JSON.parse(raw); + const result = validateCV(parsed); + if (result.success) { + const last = rawLastSaved ? new Date(rawLastSaved) : null; + hydrate(result.data, last ?? null); + setSaveStatus('saved'); + } else { + setSaveError('Found invalid draft in storage; ignoring.'); + setSaveStatus('error'); + } + } + } catch (e: any) { + setSaveError('Failed to load draft from storage.'); + setSaveStatus('error'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [storageKey]); + + // Debounced autosave when dirty + useEffect(() => { + if (!isDirty) return; + setSaveStatus('saving'); + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + timerRef.current = window.setTimeout(() => { + try { + const json = JSON.stringify(cv); + localStorage.setItem(storageKey, json); + const nowIso = new Date().toISOString(); + localStorage.setItem(`${storageKey}:lastSaved`, nowIso); + markSaved(new Date(nowIso)); + } catch (e: any) { + setSaveError(e?.message || 'Failed to save to storage.'); + setSaveStatus('error'); + } + }, debounceMs); + + return () => { + if (timerRef.current) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [cv, isDirty, storageKey, debounceMs, setSaveStatus, setSaveError, markSaved]); +} \ No newline at end of file diff --git a/cv-engine/src/store/cvStore.ts b/cv-engine/src/store/cvStore.ts index 1fa8771..ccc6fb7 100644 --- a/cv-engine/src/store/cvStore.ts +++ b/cv-engine/src/store/cvStore.ts @@ -13,10 +13,13 @@ interface CvState { activeStep: EditorStep; isDirty: boolean; lastSaved: Date | null; + saveStatus: 'idle' | 'saving' | 'saved' | 'error'; + saveError: string | null; errors: Record; // Actions setCv: (cv: z.infer) => void; + hydrate: (cv: z.infer, lastSaved?: Date | null) => void; updatePersonal: (personal: z.infer['personal']) => void; updateWork: (work: z.infer['work']) => void; updateEducation: (education: z.infer['education']) => void; @@ -50,6 +53,9 @@ interface CvState { // State management setDirty: (isDirty: boolean) => void; setLastSaved: (date: Date | null) => void; + setSaveStatus: (status: CvState['saveStatus']) => void; + setSaveError: (error: string | null) => void; + markSaved: (date?: Date) => void; validateActiveSection: () => boolean; resetErrors: () => void; } @@ -61,10 +67,14 @@ export const useCvStore = create((set, get) => ({ activeStep: 'personal', isDirty: false, lastSaved: null, + saveStatus: 'idle', + saveError: null, errors: {}, // Actions setCv: (cv) => set({ cv, isDirty: true }), + + hydrate: (cv, lastSaved) => set({ cv, isDirty: false, lastSaved: lastSaved ?? null }), updatePersonal: (personal) => { const result = validateSection('personal', personal); @@ -274,6 +284,9 @@ export const useCvStore = create((set, get) => ({ // State management setDirty: (isDirty) => set({ isDirty }), setLastSaved: (lastSaved) => set({ lastSaved }), + setSaveStatus: (saveStatus) => set({ saveStatus }), + setSaveError: (saveError) => set({ saveError }), + markSaved: (date) => set({ lastSaved: date ?? new Date(), isDirty: false, saveStatus: 'saved', saveError: null }), validateActiveSection: () => { const { activeStep, cv } = get(); @@ -366,4 +379,6 @@ export const useSummaryJson = () => useCvStore(state => state.cv.summaryJson); export const useTemplateId = () => useCvStore(state => state.cv.templateId); export const useActiveStep = () => useCvStore(state => state.activeStep); export const useIsDirty = () => useCvStore(state => state.isDirty); -export const useLastSaved = () => useCvStore(state => state.lastSaved); \ No newline at end of file +export const useLastSaved = () => useCvStore(state => state.lastSaved); +export const useSaveStatus = () => useCvStore(state => state.saveStatus); +export const useSaveError = () => useCvStore(state => state.saveError); \ No newline at end of file