feat(draft): add local autosave + restore prompt + publish label
This commit is contained in:
121
atomizer-dashboard/frontend/src/hooks/useSpecDraft.ts
Normal file
121
atomizer-dashboard/frontend/src/hooks/useSpecDraft.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
// =====================================================================
|
||||
|
||||
Reference in New Issue
Block a user