feat(autosave): add local autosave functionality with status indicator
implement autosave hook that persists CV data to localStorage with debounce add status component to display save state in header extend store to track save status and errors
This commit is contained in:
parent
3468cf7a43
commit
adeb8473b7
@ -7,9 +7,12 @@ import SkillsEditor from './editors/SkillsEditor';
|
|||||||
import SummaryEditor from './editors/SummaryEditor';
|
import SummaryEditor from './editors/SummaryEditor';
|
||||||
import PreviewPanel from './components/PreviewPanel';
|
import PreviewPanel from './components/PreviewPanel';
|
||||||
import { useActiveStep } from './store/cvStore';
|
import { useActiveStep } from './store/cvStore';
|
||||||
|
import { useLocalAutosave } from './hooks/useLocalAutosave';
|
||||||
|
import AutosaveStatus from './components/AutosaveStatus';
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const activeStep = useActiveStep();
|
const activeStep = useActiveStep();
|
||||||
|
useLocalAutosave('cv-engine:draft', 800);
|
||||||
|
|
||||||
// Render the appropriate editor based on the active step
|
// Render the appropriate editor based on the active step
|
||||||
const renderEditor = () => {
|
const renderEditor = () => {
|
||||||
@ -35,7 +38,10 @@ const App: React.FC = () => {
|
|||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<header className="bg-white shadow-sm">
|
<header className="bg-white shadow-sm">
|
||||||
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">CV Engine</h1>
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">CV Engine</h1>
|
||||||
|
<AutosaveStatus />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
28
cv-engine/src/components/AutosaveStatus.tsx
Normal file
28
cv-engine/src/components/AutosaveStatus.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useSaveStatus, useLastSaved, useSaveError } from '../store/cvStore';
|
||||||
|
|
||||||
|
interface AutosaveStatusProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AutosaveStatus: React.FC<AutosaveStatusProps> = ({ 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 (
|
||||||
|
<div className={`text-sm ${color} ${className}`} aria-live="polite" role="status">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AutosaveStatus;
|
||||||
66
cv-engine/src/hooks/useLocalAutosave.ts
Normal file
66
cv-engine/src/hooks/useLocalAutosave.ts
Normal file
@ -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<number | null>(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]);
|
||||||
|
}
|
||||||
@ -13,10 +13,13 @@ interface CvState {
|
|||||||
activeStep: EditorStep;
|
activeStep: EditorStep;
|
||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
lastSaved: Date | null;
|
lastSaved: Date | null;
|
||||||
|
saveStatus: 'idle' | 'saving' | 'saved' | 'error';
|
||||||
|
saveError: string | null;
|
||||||
errors: Record<string, string[]>;
|
errors: Record<string, string[]>;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setCv: (cv: z.infer<typeof CvSchema>) => void;
|
setCv: (cv: z.infer<typeof CvSchema>) => void;
|
||||||
|
hydrate: (cv: z.infer<typeof CvSchema>, lastSaved?: Date | null) => void;
|
||||||
updatePersonal: (personal: z.infer<typeof CvSchema>['personal']) => void;
|
updatePersonal: (personal: z.infer<typeof CvSchema>['personal']) => void;
|
||||||
updateWork: (work: z.infer<typeof CvSchema>['work']) => void;
|
updateWork: (work: z.infer<typeof CvSchema>['work']) => void;
|
||||||
updateEducation: (education: z.infer<typeof CvSchema>['education']) => void;
|
updateEducation: (education: z.infer<typeof CvSchema>['education']) => void;
|
||||||
@ -50,6 +53,9 @@ interface CvState {
|
|||||||
// State management
|
// State management
|
||||||
setDirty: (isDirty: boolean) => void;
|
setDirty: (isDirty: boolean) => void;
|
||||||
setLastSaved: (date: Date | null) => void;
|
setLastSaved: (date: Date | null) => void;
|
||||||
|
setSaveStatus: (status: CvState['saveStatus']) => void;
|
||||||
|
setSaveError: (error: string | null) => void;
|
||||||
|
markSaved: (date?: Date) => void;
|
||||||
validateActiveSection: () => boolean;
|
validateActiveSection: () => boolean;
|
||||||
resetErrors: () => void;
|
resetErrors: () => void;
|
||||||
}
|
}
|
||||||
@ -61,11 +67,15 @@ export const useCvStore = create<CvState>((set, get) => ({
|
|||||||
activeStep: 'personal',
|
activeStep: 'personal',
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
lastSaved: null,
|
lastSaved: null,
|
||||||
|
saveStatus: 'idle',
|
||||||
|
saveError: null,
|
||||||
errors: {},
|
errors: {},
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setCv: (cv) => set({ cv, isDirty: true }),
|
setCv: (cv) => set({ cv, isDirty: true }),
|
||||||
|
|
||||||
|
hydrate: (cv, lastSaved) => set({ cv, isDirty: false, lastSaved: lastSaved ?? null }),
|
||||||
|
|
||||||
updatePersonal: (personal) => {
|
updatePersonal: (personal) => {
|
||||||
const result = validateSection('personal', personal);
|
const result = validateSection('personal', personal);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@ -274,6 +284,9 @@ export const useCvStore = create<CvState>((set, get) => ({
|
|||||||
// State management
|
// State management
|
||||||
setDirty: (isDirty) => set({ isDirty }),
|
setDirty: (isDirty) => set({ isDirty }),
|
||||||
setLastSaved: (lastSaved) => set({ lastSaved }),
|
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: () => {
|
validateActiveSection: () => {
|
||||||
const { activeStep, cv } = get();
|
const { activeStep, cv } = get();
|
||||||
@ -367,3 +380,5 @@ export const useTemplateId = () => useCvStore(state => state.cv.templateId);
|
|||||||
export const useActiveStep = () => useCvStore(state => state.activeStep);
|
export const useActiveStep = () => useCvStore(state => state.activeStep);
|
||||||
export const useIsDirty = () => useCvStore(state => state.isDirty);
|
export const useIsDirty = () => useCvStore(state => state.isDirty);
|
||||||
export const useLastSaved = () => useCvStore(state => state.lastSaved);
|
export const useLastSaved = () => useCvStore(state => state.lastSaved);
|
||||||
|
export const useSaveStatus = () => useCvStore(state => state.saveStatus);
|
||||||
|
export const useSaveError = () => useCvStore(state => state.saveError);
|
||||||
Loading…
x
Reference in New Issue
Block a user