diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index e3adbac9..3cd7513c 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -1,8 +1,9 @@ "use client"; -import { Suspense, useState } from "react"; +import { Suspense, useState, useMemo, useEffect } from "react"; import { useAccount } from "wagmi"; import { useSearchParams } from "next/navigation"; +import { useDraft } from "../../hooks/useDraft"; import { useQuery } from "@tanstack/react-query"; import { validateContentLength, @@ -120,6 +121,26 @@ function CreatePage() { : false; const newBusy = newState !== "idle" && newState !== "error"; + // ---- New Storyline draft auto-save ---- + const newDraftValues = useMemo( + () => ({ title: newTitle, content: newContent, genre, language }), + [newTitle, newContent, genre, language], + ); + const newDraftSetters = useMemo( + () => ({ + title: setNewTitle, + content: setNewContent, + genre: setGenre, + language: setLanguage, + }), + [], + ); + const { + restored: newDraftRestored, + clearDraft: clearNewDraft, + discardDraft: discardNewDraft, + } = useDraft("plotlink_draft_create", newDraftValues, newDraftSetters); + // ---- Chain Plot state ---- const prefillStoryline = searchParams.get("storyline"); const [chainStorylineId, setChainStorylineId] = useState( @@ -161,6 +182,30 @@ function CreatePage() { chainValid; const chainBusy = chainState !== "idle" && chainState !== "error"; + // ---- Chain Plot draft auto-save ---- + const chainDraftKey = chainStorylineId + ? `plotlink_draft_plot_${chainStorylineId}` + : "plotlink_draft_plot"; + const chainDraftValues = useMemo( + () => ({ title: chainTitle, content: chainContent }), + [chainTitle, chainContent], + ); + const chainDraftSetters = useMemo( + () => ({ title: setChainTitle, content: setChainContent }), + [], + ); + const { + restored: chainDraftRestored, + clearDraft: clearChainDraft, + discardDraft: discardChainDraft, + } = useDraft(chainDraftKey, chainDraftValues, chainDraftSetters); + + // Clear drafts on successful publish (must be above early returns — Rules of Hooks) + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { if (newState === "published") clearNewDraft(); }, [newState]); + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { if (chainState === "published") clearChainDraft(); }, [chainState]); + if (!isConnected) { return (
@@ -306,6 +351,21 @@ function CreatePage() { }} className="mt-6 space-y-6" > + {newDraftRestored && ( +
+ Draft restored + +
+ )} + {!newDraftRestored && (newTitle || newContent) && ( +
+ +
+ )}
+ {chainDraftRestored && ( +
+ Draft restored + +
+ )} + {!chainDraftRestored && (chainTitle || chainContent) && ( +
+ +
+ )}
{loadingStorylines ? ( diff --git a/src/hooks/useDraft.ts b/src/hooks/useDraft.ts new file mode 100644 index 00000000..4ef44420 --- /dev/null +++ b/src/hooks/useDraft.ts @@ -0,0 +1,109 @@ +"use client"; + +import { useEffect, useRef, useState, useCallback } from "react"; + +const DEBOUNCE_MS = 1000; + +/** + * Auto-save and restore draft content from localStorage. + * Debounces writes by 1 second. Returns restore/discard helpers. + */ +export function useDraft>( + key: string, + currentValues: T, + setters: { [K in keyof T]: (val: T[K]) => void }, +) { + const [restored, setRestored] = useState(false); + const timerRef = useRef | null>(null); + const hasContent = useRef(false); + const prevKeyRef = useRef(key); + + // Restore on mount or key change; reset fields if no draft for new key + useEffect(() => { + try { + const raw = localStorage.getItem(key); + if (!raw) { + // Key changed and no draft exists — reset fields to prevent stale saves + if (prevKeyRef.current !== key) { + for (const k of Object.keys(setters) as (keyof T)[]) { + (setters[k] as (val: unknown) => void)(""); + } + } + prevKeyRef.current = key; + return; + } + const saved = JSON.parse(raw) as Partial; + let didRestore = false; + for (const k of Object.keys(saved) as (keyof T)[]) { + if (saved[k] !== undefined && saved[k] !== "" && k in setters) { + (setters[k] as (val: unknown) => void)(saved[k]); + didRestore = true; + } + } + if (didRestore) setRestored(true); + } catch { + // Corrupt data — ignore + } + prevKeyRef.current = key; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); + + // Auto-dismiss "Draft restored" after 3 seconds + useEffect(() => { + if (!restored) return; + const t = setTimeout(() => setRestored(false), 3000); + return () => clearTimeout(t); + }, [restored]); + + // Debounced save + useEffect(() => { + // Check if there's any non-empty content + const hasData = Object.values(currentValues).some( + (v) => typeof v === "string" ? v.length > 0 : v !== undefined && v !== null, + ); + hasContent.current = hasData; + + if (!hasData) { + localStorage.removeItem(key); + return; + } + + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + localStorage.setItem(key, JSON.stringify(currentValues)); + }, DEBOUNCE_MS); + + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [key, currentValues]); + + // beforeunload warning + useEffect(() => { + const handler = (e: BeforeUnloadEvent) => { + if (hasContent.current) { + e.preventDefault(); + } + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, []); + + const clearDraft = useCallback(() => { + localStorage.removeItem(key); + hasContent.current = false; + }, [key]); + + const discardDraft = useCallback(() => { + localStorage.removeItem(key); + hasContent.current = false; + for (const k of Object.keys(setters) as (keyof T)[]) { + (setters[k] as (val: unknown) => void)( + typeof currentValues[k] === "string" ? "" : null, + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, setters]); + + return { restored, clearDraft, discardDraft }; +}