'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Unsaved editor state that survives leaving the editor (#592).
*
* `useUnsavedGuard` only covers leaving the browser. Inside the admin, a tab
* link, the back button, Reload or Logout unmounts the page builder or the
* journal editor and its state goes with it. Rather than guard every exit —
* App Router has no `router.events`, and `` does not see the
* back button — the editor keeps a draft in `sessionStorage` and picks it up
* again when it next mounts.
*
* A draft carries the `base` it was edited from: a fingerprint of what the
* server returned. When the server still returns the same thing the draft is
* applied on load. When it does not — the file was saved from another tab, or a
* backup was restored — applying it would silently overwrite that change, so it
* is held back and the editor asks.
*
* `sessionStorage` rather than `localStorage`: a draft belongs to the tab it was
* typed in, and should not follow the operator into a second tab or outlive the
* browser session.
*/
const PREFIX = 'folio_draft_';
export interface Draft {
base: string;
value: T;
}
/**
* Storage can throw — a private window, a full quota, blocked site data. A
* draft is a convenience, so every failure degrades to "no draft".
*/
export function readDraft(key: string): Draft | null {
try {
const raw = window.sessionStorage.getItem(PREFIX + key);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial>;
if (typeof parsed?.base !== 'string' || !('value' in parsed)) return null;
return { base: parsed.base, value: parsed.value as T };
} catch {
return null;
}
}
export function writeDraft(key: string, draft: Draft): void {
try {
window.sessionStorage.setItem(PREFIX + key, JSON.stringify(draft));
} catch {
// Quota or blocked storage: the edit is still in memory, just not kept.
}
}
export function clearDraft(key: string): void {
try {
window.sessionStorage.removeItem(PREFIX + key);
} catch {
// Nothing to clean up if storage is unavailable.
}
}
/**
* What the editor should show after loading:
* - `restored` — a draft was applied; offer to discard it.
* - `conflict` — a draft exists but the server changed since; offer to apply
* it anyway or discard it.
*/
export type DraftStatus = 'none' | 'restored' | 'conflict';
export function useDraft(key: string, value: T, dirty: boolean) {
/** The server fingerprint the current edits started from; null until loaded. */
const baseRef = useRef(null);
const [status, setStatus] = useState('none');
// Mirror the edits while there are any, and drop the draft once there are
// none — a save or a discard. Nothing is written before `load()` has set a
// base, so the empty state an editor mounts with never lands in storage.
useEffect(() => {
if (baseRef.current === null) return;
if (dirty) writeDraft(key, { base: baseRef.current, value });
else clearDraft(key);
}, [key, value, dirty]);
/**
* Call once the server state has loaded, with its fingerprint. Returns the
* draft to apply, or null to keep what the server sent.
*/
const load = useCallback(
(base: string): T | null => {
baseRef.current = base;
const draft = readDraft(key);
if (!draft) {
setStatus('none');
return null;
}
if (draft.base === base) {
setStatus('restored');
return draft.value;
}
setStatus('conflict');
return null;
},
[key],
);
/** Call after a successful save, with the fingerprint of what was saved. */
const saved = useCallback(
(base: string) => {
baseRef.current = base;
clearDraft(key);
setStatus('none');
},
[key],
);
/** The held-back draft of a `conflict`, for "restore anyway". */
const takeConflicting = useCallback((): T | null => {
const draft = readDraft(key);
setStatus(draft ? 'restored' : 'none');
return draft?.value ?? null;
}, [key]);
/** Forget the draft. The editor reloads the server state itself. */
const discard = useCallback(() => {
clearDraft(key);
setStatus('none');
}, [key]);
const dismiss = useCallback(() => setStatus('none'), []);
return { status, load, saved, takeConflicting, discard, dismiss };
}