feat(draft): add local autosave + restore prompt + publish label

This commit is contained in:
2026-01-29 03:16:31 +00:00
parent b3f3329c79
commit a7039c5875
3 changed files with 211 additions and 9 deletions

View File

@@ -0,0 +1,121 @@
/**
* useSpecDraft (S2 Draft + Publish)
*
* Local autosave for AtomizerSpec so users don't lose work.
* "Publish" still uses useSpecStore.saveSpec() to write atomizer_spec.json.
*
* NOTE: This is a partial S2 implementation because the current store
* still patches the backend during edits. This draft layer still provides:
* - crash/refresh protection
* - explicit restore/discard prompt
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { AtomizerSpec } from '../types/atomizer-spec';
const draftKey = (studyId: string) => `atomizer:draft:${studyId}`;
type DraftPayload = {
spec: AtomizerSpec;
baseHash: string | null;
updatedAt: number;
};
export function useSpecDraft(params: {
studyId: string | null | undefined;
spec: AtomizerSpec | null | undefined;
serverHash: string | null | undefined;
enabled?: boolean;
}) {
const { studyId, spec, serverHash, enabled = true } = params;
const [hasDraft, setHasDraft] = useState(false);
const [draft, setDraft] = useState<DraftPayload | null>(null);
// Debounce writes
const writeTimer = useRef<number | null>(null);
const key = useMemo(() => (studyId ? draftKey(studyId) : null), [studyId]);
const loadDraft = useCallback(() => {
if (!enabled || !key) return null;
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw) as DraftPayload;
if (!parsed?.spec) return null;
return parsed;
} catch {
return null;
}
}, [enabled, key]);
const discardDraft = useCallback(() => {
if (!enabled || !key) return;
localStorage.removeItem(key);
setHasDraft(false);
setDraft(null);
}, [enabled, key]);
const saveDraftNow = useCallback(
(payload: DraftPayload) => {
if (!enabled || !key) return;
try {
localStorage.setItem(key, JSON.stringify(payload));
setHasDraft(true);
setDraft(payload);
} catch {
// ignore storage failures
}
},
[enabled, key]
);
// Load draft on study change
useEffect(() => {
if (!enabled || !key) return;
const existing = loadDraft();
if (existing) {
setHasDraft(true);
setDraft(existing);
} else {
setHasDraft(false);
setDraft(null);
}
}, [enabled, key, loadDraft]);
// Autosave whenever spec changes
useEffect(() => {
if (!enabled || !key) return;
if (!studyId || !spec) return;
// Clear existing debounce
if (writeTimer.current) {
window.clearTimeout(writeTimer.current);
writeTimer.current = null;
}
writeTimer.current = window.setTimeout(() => {
saveDraftNow({ spec, baseHash: serverHash ?? null, updatedAt: Date.now() });
}, 750);
return () => {
if (writeTimer.current) {
window.clearTimeout(writeTimer.current);
writeTimer.current = null;
}
};
}, [enabled, key, studyId, spec, serverHash, saveDraftNow]);
return {
hasDraft,
draft,
discardDraft,
reloadDraft: () => {
const d = loadDraft();
setDraft(d);
setHasDraft(Boolean(d));
return d;
},
};
}

View File

@@ -63,6 +63,9 @@ interface SpecStoreActions {
// WebSocket integration - set spec directly without API call
setSpecFromWebSocket: (spec: AtomizerSpec, studyId?: string) => void;
// Local draft integration (S2) - set spec locally (no API call) and mark dirty
setSpecLocalDraft: (spec: AtomizerSpec, studyId?: string) => void;
// Full spec operations
saveSpec: (spec: AtomizerSpec) => Promise<void>;
replaceSpec: (spec: AtomizerSpec) => Promise<void>;
@@ -402,6 +405,20 @@ export const useSpecStore = create<SpecStore>()(
});
},
// Set spec locally as a draft (no API call). This is used by DraftManager (S2).
// Marks the spec as dirty to indicate "not published".
setSpecLocalDraft: (spec: AtomizerSpec, studyId?: string) => {
const currentStudyId = studyId || get().studyId;
console.log('[useSpecStore] Setting spec from local draft:', spec.meta?.study_name);
set({
spec,
studyId: currentStudyId,
isLoading: false,
isDirty: true,
error: null,
});
},
// =====================================================================
// Full Spec Operations
// =====================================================================